@k8ordo/i18n

Messages

A message is a function declared on its own with message(). Its text in every locale sits side by side, and it returns the string for the locale where it is called. There is no key list and no dictionary object: TypeScript checks that nothing is missing, and the bundler decides what reaches the browser.

Text messages

Key the text by locale. What comes back is a function of no arguments (Message), which reads the current locale each time it is called.

// src/messages/nav.ts
import { message } from '@k8ordo/i18n';

export const home = message({ ja: 'ホーム', en: 'Home' });

export const search = message({ ja: '検索', en: 'Search' });

When nothing names a locale — a call on the server outside any request, a URL without a locale segment — it returns the default locale's text. In an environment where no set has registered yet, it returns the first text written.

Messages that take values

A message that embeds values is a function of the same parameters in every locale. Annotate the parameter types on one of them and the rest are held to those types.

// src/messages/cart.ts
import { message } from '@k8ordo/i18n';

export const items = message({
  ja: (count: number) => `${String(count)} 件`,
  en: (count) =>
    `${String(count)} ${new Intl.PluralRules('en').select(count) === 'one' ? 'item' : 'items'}`,
});

export const updated = message({
  ja: (date: Date) =>
    `${new Intl.DateTimeFormat('ja', { dateStyle: 'long', timeZone: 'UTC' }).format(date)} 更新`,
  en: (date) =>
    `Updated ${new Intl.DateTimeFormat('en', { dateStyle: 'long', timeZone: 'UTC' }).format(date)}`,
});

There is no syntax to learn. Interpolation is a template literal, plurals are Intl.PluralRules, and dates and numbers are Intl.DateTimeFormat and Intl.NumberFormat. Each locale's function is already tied to its locale, so the tag handed to Intl is written right there.

Because a message is an ordinary function, TypeScript checks the arguments at every call site, and no parser for a message syntax has to ship in the bundle.

  • If no locale annotates its parameters, they are inferred as unknown, so any value passes for each argument. Annotate at least one.
  • Text and functions cannot be mixed within one message, so a locale that does not use the value is still a function. Declare the parameter there too (en: (_count) => 'Items'): depending on which locale holds it, a function with no parameters either makes TypeScript infer that the message takes none, so the declaration fails to compile, or slips past the check unnoticed.
  • Give a date formatter an explicit timeZone. Without one, the server formats in its own time zone and the browser in the visitor's, so text a Client Component renders can differ between the HTML and hydration.

Message and Variants

What message() returns is a Message<Args>: a text message is a Message (() => string), and a message that takes values carries its parameter tuple, as in Message<[count: number]>.

A component or a piece of data that carries text holds a Message, not a string, and the component that renders it calls it. Only the renderer turns it into a string, so whatever builds the data never needs to know the locale. This site's navigation data holds label: Message the same way.

// src/components/nav-list.tsx
import type { Message } from '@k8ordo/i18n';

export type NavItem = { href: string; label: Message };

export function NavList({ items }: { items: readonly NavItem[] }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.href}>
          <a href={item.href}>{item.label()}</a>
        </li>
      ))}
    </ul>
  );
}

Variants<V> is the type of one value per registered locale (Readonly<Record<RegisteredLocale, V>>). It also fits values other than messages that must exist for every locale, such as the language names a switcher lists. RegisteredLocale is the locale union once Register is merged, and string before.

// src/locale-names.ts
import type { Variants } from '@k8ordo/i18n';

export const LOCALE_NAMES: Variants<string> = {
  ja: '日本語',
  en: 'English',
};

What the compiler checks

Once Register is merged, none of these compile.

CodeWhy
message({ ja: 'ホーム' })A locale is missing
message({ ja: 'ホーム', en: 'Home', fr: 'Accueil' })A locale outside the set
message({ ja: '件数', en: () => 'Items' })Text and a function mixed
message({ ja: (count: number) => …, en: (count: string) => … })The parameters differ between locales
nav.home('x')An argument passed to a text message
cart.items('3')An argument of the wrong type

A missing or mixed locale is reported at the message( call as "No overload matches this call". TypeScript attaches the detail for the last overload — the function form — so even for a text message with a locale missing, the detail says a string is not assignable to a function. What needs fixing is the missing key.

Before Register is merged, RegisteredLocale is string, so missing locales are not checked.

A missing locale that gets past the types (a call from JavaScript, or as) becomes a TypeError where the message is read, not where it is declared (message: no text for "en" in ["ja"]). Checking and throwing at the declaration would make it a side effect in the bundler's eyes, and unused messages could no longer be dropped.

Call it where the text is rendered

A message reads the locale at the moment it is called. Called at the top of a module, it makes its string once, when the module is first evaluated, and every page keeps using that string. On the server that is usually outside any request, so it is the default locale's string; a module first loaded during a request gets that request's locale instead. In the browser it stays in the locale of the URL the module loaded under, even after a language switch.

Called at module scope

// src/data/menu.ts
import * as m from '../messages';

export const MENU_LABELS = [m.nav.home(), m.nav.search()];

Keep the Message, call it when rendering

// src/data/menu.ts
import type { Message } from '@k8ordo/i18n';

import * as m from '../messages';

export const MENU_LABELS: readonly Message[] = [m.nav.home, m.nav.search];

The same holds for the error messages of a schema and for getLocale(): do not compute the value ahead of time; hand the function along and call it where it is used.

Read the example with @k8ordo/form error messages

Where messages live

Anywhere. What reads well is one file per area, re-exported as a namespace from an index module, so a call site reads m.nav.home().

// src/messages/index.ts
export * as nav from './nav';
export * as cart from './cart';
export * as 'static' from './static';
// src/components/header.tsx
import * as m from '../messages';

export function Header() {
  return (
    <header>
      <a href="/">{m.nav.home()}</a>
      <span>{m.static.title()}</span>
    </header>
  );
}

To name a namespace with a reserved word such as static, export it under a string name (ES2022). The call site still reads it as a property, as in m.static.title().

A message only one component uses can sit next to that component. Related messages can be grouped in an object (export const dialog = { title: message(…), close: message(…) }); the bundler then keeps the object whole.

This site keeps one file per area under src/messages/ (nav.ts, home.ts, one file per guide page such as i18n-messages.ts), and index.ts re-exports each as a namespace. A key that would have three levels is a group object, as in m.components.button.description.

Across the Server Component boundary

A message is a function, and a function cannot be passed from a Server Component to a Client Component as a prop. Try it and the render fails, because React cannot serialize the function.

Pass the string

The Server Component calls the message and passes the string. The string is made in the locale of that render, and only that locale's text travels in the RSC payload. The message itself stays out of the client bundle.

// src/routes/[locale]/share/page.tsx
import * as m from '../../../messages';
import { CopyLink } from './_parts/copy-link';

export default function SharePage() {
  return <CopyLink copied={m.share.copied()} label={m.share.copyLink()} />;
}
// src/routes/[locale]/share/_parts/copy-link.tsx
'use client';

import { useState } from 'react';

export function CopyLink({ copied, label }: { copied: string; label: string }) {
  const [done, setDone] = useState(false);

  return (
    <button
      onClick={() => {
        void navigator.clipboard.writeText(location.href).then(() => {
          setDone(true);
        });
      }}
      type="button"
    >
      {done ? copied : label}
    </button>
  );
}

Or import it in the Client Component

There is no need to thread strings down several levels: a Client Component can import a message and call it itself. In exchange, that message ships to the client in every locale. As a rule of thumb, import it when the text has to change in the browser (it depends on input, or is called after hydration), and pass the string when the server already knows it.

// src/routes/[locale]/share/_parts/copy-link.tsx
'use client';

import { useState } from 'react';

import * as m from '../../../../messages';

export function CopyLink() {
  const [done, setDone] = useState(false);

  return (
    <button
      onClick={() => {
        void navigator.clipboard.writeText(location.href).then(() => {
          setDone(true);
        });
      }}
      type="button"
    >
      {done ? m.share.copied() : m.share.copyLink()}
    </button>
  );
}

Components without a directive

A component without a directive is shared: rendered by a Server Component it runs on the server, rendered by a Client Component it runs in the browser, and either way a Message prop never crosses the boundary. A component that only reads props and messages — a page title, a landing layout — is best left without 'use client' for exactly this reason. This site's PageTitle, and the DocPage that lays out this guide, are written that way.

What reaches the browser

message() does nothing at the declaration: it returns a function and touches no global state. So a bundler treats a message that nothing references as dead code.

  • The client bundle carries exactly the messages that 'use client' modules — and the modules they import — name, each in every locale.
  • Text a Server Component rendered costs the client nothing, whichever module declares it.
  • This is why messages are exports rather than entries in one dictionary object: a dictionary is kept or dropped whole.

After a build, search the JavaScript in dist/client/assets/ for text only a Server Component renders to confirm that it never reached the client.