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
loaderfunction, 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 assignedOnce 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:
- exact match against
locales - language-prefix match (
fr-CA→fr) - 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:
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:
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
- Locale detection, matching browser, header, cookie, and URL inputs
- Dynamic loading, lazy locale loading patterns
- React integration,
SayProvider,useSay, server runtime