@k8ordo/i18n

Dates & numbers

Plurals, dates, numbers and lists are Intl itself. The locale set draws the Intl object for the current locale — and, for a date, that locale’s time zone — and reuses what it made. There is no format syntax of its own.

The Intl the set draws

Each is a member of the set, not a hook: call it in a Server Component, in a Client Component, or inside a message.

MemberReturns
dateTimeFormat(options?)Intl.DateTimeFormat in the current locale and that locale's timeZone.
numberFormat(options?)Intl.NumberFormat in the current locale.
relativeTimeFormat(options?)Intl.RelativeTimeFormat in the current locale.
pluralRules(options?)Intl.PluralRules in the current locale.
listFormat(options?)Intl.ListFormat in the current locale.

What comes back is the Intl object itself, so format, formatToParts, formatRange, select and resolvedOptions are Intl’s own, and so are the options.

For an Intl API not listed here (Intl.Collator, Intl.DisplayNames, …), pass the tag from getLocale() and make it yourself.

Dates in the locale's time zone

dateTimeFormat writes a date only in the timeZone the locale was defined with.

tsx
// src/components/published-at.tsx
import { locales } from '../i18n';

export function PublishedAt({ date }: { date: Date }) {
  return (
    <time dateTime={date.toISOString()}>
      {locales.dateTimeFormat({ dateStyle: 'medium' }).format(date)}
    </time>
  );
}

The runtime's own time zone is the server's on the server and the visitor's in the browser. A date left to it reads one way in the server's HTML and another while hydrating — a different day, near midnight. The locale's time zone is the same on both sides, so the two agree.

The options type refuses a timeZone, and one forced through with as is overridden by the locale’s.

ts
// the type refuses it — the locale's time zone is the only one
locales.dateTimeFormat({ dateStyle: 'medium', timeZone: 'UTC' });

A display that should follow the visitor's own zone — a local clock, a time relative to now — necessarily differs between the server and the browser. Use Intl directly for it, in a part that renders in the browser only.

Inside a message

Called inside a message's function, plurals and dates become part of the message. The function runs when its locale is the current one.

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

import { locales } from '../i18n';

export const items = message({
  ja: (count: number) => `${locales.numberFormat().format(count)} 件`,
  en: (count) =>
    `${locales.numberFormat().format(count)} ${locales.pluralRules().select(count) === 'one' ? 'item' : 'items'}`,
});

export const updated = message({
  ja: (date: Date) =>
    `${locales.dateTimeFormat({ dateStyle: 'long' }).format(date)} 更新`,
  en: (date) =>
    `Updated ${locales.dateTimeFormat({ dateStyle: 'long' }).format(date)}`,
});

Made once, reused

An Intl object is costly to make, so one is made per locale and options, and the same one is returned after that.

Options are told apart by their JSON: the same options spelled in another order make a second object, never a different answer. Calling it on every render does not make a new one.