SayKit
Core Concepts

Runtime

The Say class, locales, messages, activation, matching, freezing

The Say class is SayKit's runtime. It holds your locales and message catalogues, tracks the active locale, and formats compiled messages on demand.

import { Say } from 'saykit';

You usually create one Say per app. Framework integrations wrap that instance, SayProvider in React, SayPlugin in Carbon, but the underlying API is the same everywhere.

Creating an instance

import { Say } from 'saykit';
import en from './locales/en.po';
import fr from './locales/fr.po';

const say = new Say({
  locales: ['en', 'fr'],
  messages: { en, fr },
});

You can pass:

  • inline messages, useful when catalogues are bundled at build time
  • a loader function, useful when you want to fetch or import on demand
  • both, preload some, lazy-load the rest
const say = new Say({
  locales: ['en', 'fr', 'ja'],
  messages: { en },
  loader: async (locale) => {
    const mod = await import(`./locales/${locale}.po`);
    return mod.default;
  },
});

See dynamic loading for a deeper look at the loader pattern.

Reading state

A Say instance exposes three properties:

say.locales; // ['en', 'fr']
say.locale; // 'en' (currently active)
say.messages; // { 'abc123': 'Hello!', ... } (for the active locale)

say.locale throws if no locale is active. say.messages throws if no catalogue is loaded for the active locale. Activate a locale before reading either.

Loading messages

load() runs your configured loader for the given locales:

await say.load('fr');
await say.load('fr', 'ja');

With no arguments, every configured locale is loaded:

await say.load();

Locales that are already loaded are skipped. Calling load() without a configured loader throws.

If your loader is synchronous, load() is synchronous too. If your loader returns a promise, so does load().

Assigning messages

Use assign() when you already have messages in hand and want to attach them directly, no loader required.

say.assign('fr', {
  greeting: 'Bonjour !',
});

Or for multiple locales at once:

say.assign({
  en: { greeting: 'Hello!' },
  fr: { greeting: 'Bonjour !' },
});

This is useful when messages are embedded in the bundle, hydrated from a server, or pulled from another store.

Activating a locale

A locale must be loaded (or assigned) before you can activate it:

await say.load('fr');
say.activate('fr');
say.activate('fr'); // throws if `fr` has not been loaded or assigned

Once a locale is active, compiled say.call(...) invocations resolve against its catalogue.

Matching guesses

Use match() to pick the best supported locale from a list of guesses, browser locales, an Accept-Language header, a URL segment, whatever:

say.match(['fr-CA', 'en-US']);
// → 'fr' if your configured locales include 'fr'

Matching prefers in order:

  1. exact match against locales
  2. language-prefix match (fr-CAfr)
  3. the first configured locale as final fallback

match() takes any number of strings or arrays of strings; everything is flattened:

say.match('fr-CA', 'en-US');
say.match(['fr-CA', 'en-US']);
say.match(headerLocales, [cookieLocale]);

See locale detection for usage patterns.

Cloning

clone() returns a fresh Say with the same locales, messages, and loader:

const requestSay = say.clone();
requestSay.activate('fr');

Cloning is the right way to handle a per-request, per-interaction, or per-render locale without mutating a shared instance. The Carbon and React server integrations do this for you under the hood.

Freezing

freeze() returns a read-only view:

const readonlySay = say.clone().activate('fr').freeze();

A frozen Say can still:

  • read locale, locales, messages
  • call match()
  • format messages via say.call(...)

But it can no longer:

  • load()
  • assign()
  • activate()

Freezing is how the React integration hands a Say to client components, safely scoped to one locale, with no way to mutate it accidentally.

Iterating locales

A Say instance is iterable. It yields [frozenSay, locale] for every configured locale, with each frozenSay already activated to that locale:

for (const [localised, locale] of say) {
  console.log(locale, localised`Hello!`);
}

This is how the Carbon integration generates per-locale command names and descriptions in one pass.

Formatting

Compiled macros end up calling say.call(descriptor):

say.call({
  id: 'abc123',
  name: 'Ada',
});
// → "Hello, Ada!"

In application code you rarely write this directly, the build transform generates it from your say`...` and <Say> macros. The descriptor always has an id; extra properties become ICU placeholders.

Putting it together

A typical client-side bootstrap:

src/i18n.ts
import { Say } from 'saykit';
import en from './locales/en.po';
import fr from './locales/fr.po';

const say = new Say({
  locales: ['en', 'fr'],
  messages: { en, fr },
});

const guess = navigator.language ?? 'en';
say.activate(say.match(guess));

export default say;

A typical per-request server-side bootstrap:

src/server-i18n.ts
import { Say } from 'saykit';

const baseSay = new Say({
  locales: ['en', 'fr'],
  loader: (l) => import(`./locales/${l}.po`).then((m) => m.default),
});

export async function getSayFor(headers: Headers) {
  const acceptLang = headers.get('accept-language') ?? '';
  const guesses = acceptLang.split(',').map((s) => s.split(';')[0]!.trim());

  const requestSay = baseSay.clone();
  const locale = requestSay.match(guesses);
  await requestSay.load(locale);
  return requestSay.activate(locale).freeze();
}

Next

On this page