saykit
API reference for the saykit core runtime
The saykit package exports createCatalogue, createView, createStore, and a handful of
supporting types.
import { createCatalogue, createStore, createView } from 'saykit';
import type { Catalogue, Store, View } from 'saykit';There are three objects, with one job each. A catalogue owns the locales and where each one's messages come from, whether that is inline or on demand; it never formats anything. A view is one locale bound to a set of messages: callable, immutable, and the only thing application code holds. A store holds one view at a time and says when it changed, which is what switching locale is.
createCatalogue(messages)
createCatalogue<Locale>(messages: Record<Locale, Catalogue.Source>): Catalogue<Locale>;const catalogue = createCatalogue({
en,
fr: () => import('./locales/fr.po'),
pl: () => import('./locales/pl.po'),
});The argument is the record of messages itself, so there is no options object and no separate list of
locales. Its keys are the catalogue's locales, in the order you write them, and the first of them is
the fallback match resolves to. Each value is a Catalogue.Source: the
messages themselves, or a thunk producing them, called the first time that locale is asked for.
Throws if the record is empty.
The returned catalogue is frozen. That fixes its methods; its lazy locales still fill in over time
through load.
Properties
Prop
Type
A catalogue has no active locale and no messages property. Both of those belong to a view.
locale(code)
Returns the view bound to that locale. Memoised, so asking twice returns the same view. Synchronous, so it throws for a locale whose thunk nobody has called yet, and throws differently for a locale with no messages at all.
const say = catalogue.locale('fr');loaded(code)
Whether a locale's messages are here, and so whether locale(code) will hand back a view for it.
Inline messages are here from the start; a thunk's are here once it has been loaded.
if (!catalogue.loaded('fr')) await catalogue.load('fr');load(code)
Calls a locale's thunk, if it has one and nothing has called it yet, and hands back the locale's view.
const say = await catalogue.load('fr');Returns a promise only when the thunk does. A locale whose messages are already here does not go near its thunk and comes back synchronously, which is what lets a switch between loaded locales stay in one tick. A locale with no entry at all throws.
Two loads that overlap share one call rather than fetching twice, and a locale that fills never calls its thunk again. A thunk that rejects is forgotten instead of cached, so the next load calls it afresh.
A locale is filled once, and there is no way to replace its messages afterwards. That is what lets a view be built once and stay correct: nothing can swap out the messages it was built over, so a view you are holding can never start formatting something else.
To pay every import at boot rather than on first use:
await Promise.all(catalogue.locales.map((locale) => catalogue.load(locale)));match(...guesses)
Pick the best supported locale from one or more guesses. Each argument is a Catalogue.Guess: a
string, null, undefined, or a one-level readonly array of those; all guesses are flattened in
order. Guesses come from cookies, headers and URL segments, so nullish and empty-string values are
allowed and skipped rather than throwing, and you can pass a value straight through without
narrowing it first.
catalogue.match('fr-CA', 'en');
catalogue.match(['fr-CA', 'en-US'], 'de');
catalogue.match(request.cookies.get('locale')?.value, headerGuesses);Matching order:
- exact match against
locales - language-prefix match (
fr-CA→fr) - the first of
locales, as a final fallback
Returns the matched locale.
Iteration
Catalogues are iterable. They yield [locale, say] pairs for every configured locale.
for (const [locale, say] of catalogue) {
console.log(locale, say`Hello!`);
}Each say is that locale's view, the same value catalogue.locale(locale) returns. Throws if any
configured locale has no messages loaded.
createView(locale, messages)
createView<Locale>(locale: Locale, messages: View.Messages): View<Locale>;Builds a view directly, with no catalogue behind it. A view needs nothing from a catalogue beyond a record of messages, so an app that only ever has one locale in hand can skip the catalogue entirely.
const say = createView('fr', messages);
say`Hello!`;Views built this way are not memoised or shared. When you have a catalogue, prefer
catalogue.locale(code).
View
One locale, bound to the messages it formats against. Callable, frozen, and with no reference back to the catalogue it came from, so it cannot change locale. Switching locale means holding a different view.
Properties
Prop
Type
call(descriptor)
Format a compiled message descriptor against this view's messages.
say.call({ id: 'abc123', name: 'Ada' }); // → "Hello, Ada!"The descriptor names the message by id, or carries it inline as message, which is what the
transform emits for a lone say.date(x) with no text to extract. Extra properties are ICU
placeholder values.
The transform emits each value behind one underscore ({ id: 'abc123', _name: 'Ada' }), which keeps
a message's values in their own namespace, so a value named id cannot displace the message being
looked up. call strips exactly one underscore back off, and passes keys written without one
through untouched, so a hand-written or custom-transformer-generated call works either way.
In normal application code, you don't write this, the build transform emits it from your say`...` macros.
Compiled formats are cached on the view, so a message is parsed once per locale per process.
createStore(catalogue, locale?)
createStore<Locale>(catalogue: Catalogue<Locale>, locale?: Locale): Store<Locale>;Builds a store over a catalogue, starting on locale, or on the first of
catalogue.locales if you leave it out. Throws if the starting locale has no messages loaded.
const store = createStore(catalogue, 'en');Store
Which view is current, and when that changed. A catalogue does not switch and a view cannot, so switching is its own object, and the only one that mutates.
That mutation is why a store is a browser value: one locale at a time, one user, module scope. On a server, where several locales are in flight at once, look a view up per request instead.
The returned store is frozen. What it holds changes, which is its whole job; which methods it holds does not.
Properties
Prop
Type
Because a catalogue memoises its views, switching away from a locale and back hands the same view back again.
It is read at the moment of access rather than bound once, which is what keeps store.say`...`
following the switches. Read it per call for that reason: a const say = store.say held across a
switch is the old view. The locale comes off the view, as it does anywhere else:
store.say`Hello, ${name}!`;
store.say.locale; // 'en'set(code)
Switch to another locale, loading its messages first if the catalogue does not have them yet.
store.set('fr');
await store.set('ja');Like load, this returns a promise only when the thunk does: a locale that is
already loaded switches synchronously, so a subscriber sees the new view in the same tick.
Switching to the locale that is already current does nothing and notifies nobody, though asking again for a locale still being switched to hands back the switch already in flight rather than starting a second one. A load that throws leaves the current view where it was, and the locale can be asked for again.
Only the last switch counts. If a slow locale resolves after a later set has already landed, its
result is dropped rather than applied on top, and that includes a set back to the locale you were
already on, which calls a pending switch off.
subscribe(listener)
Listen for switches. Returns a function that removes the listener.
const unsubscribe = store.subscribe((say) => render(say));The listener is called after say has changed, with the view that is now current. It is not
called on subscribe, the current view is already readable.
Macros
These are recognised by the SayKit build-tool plugins and rewritten into say.call(...)
invocations. Calling them at runtime without a plugin throws.
say`template`
The tagged-template macro.
say`Hello, ${name}!`;say({ id, context })
Descriptor form, returns a callable that you tag the template against.
say({ id: 'greeting' })`Hello!`;
say({ context: 'direction' })`Right`;say.plural(value, options)
say.plural(count, {
one: 'You have 1 item',
other: `You have ${count} items`,
});say.ordinal(value, options)
say.ordinal(position, {
1: `${position}st`,
2: `${position}nd`,
3: `${position}rd`,
other: `${position}th`,
});say.select(value, options)
say.select(gender, {
male: 'He liked it',
female: 'She liked it',
other: 'They liked it',
});say.number(value, options?)
Format a number the way the view's locale writes one. A fragment rather than a whole message, so it usually goes inside one, but it also works alone, where it is formatted but not extracted.
say`You have ${say.number(items.length)} items`;
say`Battery at ${say.number(level, { style: 'percent' })}`;
say.number(total, { style: '::currency/EUR' }); // → "€1,234.50"style is integer, percent, a skeleton, or a literal NumberFormat pattern such
as #,##0.00.
say.date(value, options?)
Format the date portion of a Date or timestamp.
say`Published ${say.date(post.publishedAt, { style: 'long' })}`;
say.date(post.publishedAt); // → "13 Sept 2026"style is short, medium, long, full, or a skeleton such as ::yMMMM.
say.time(value, options?)
Format the time portion of a Date or timestamp. Same styles as say.date; skeletons look like
::Hm.
say`Doors open at ${say.time(opensAt, { style: 'short' })}`;
say.time(opensAt); // → "19:30:00"Named placeholders
Interpolating a single-key object names the placeholder that value becomes, for the values that would otherwise be numbered. The wrapper is read at build time and never reaches the bundle.
say`Your total is ${{ cartTotal: getCartTotal() }}`; // → "Your total is {cartTotal}"
say.plural(
{ items: cart.length },
{
one: `${{ items: cart.length }} item`,
other: `${{ items: cart.length }} items`,
},
);The name must be a valid identifier, an invalid one fails the build. See Placeholder names.
Types
View.Messages
namespace View {
type Messages = { [key: string]: string };
}Catalogue.Source
Where one locale's messages come from.
namespace Catalogue {
type Source = View.Messages | (() => Produced | Promise<Produced>);
type Produced = View.Messages | { default: View.Messages };
}A thunk is normally a dynamic import, which resolves to a module rather than to the messages
themselves; the catalogue reads them off its default export. Every message is a string, so a
default holding an object is a module and never a message named default.
Store.Listener<Locale>
namespace Store {
type Listener<Locale extends string> = (view: View<Locale>) => void;
}NumeralOptions / SelectOptions
Option-bag types for plural, ordinal, and select. Both require an other key.
NumberOptions / DateTimeOptions
Option-bag types for number, date, and time. Each carries an optional style.
Skeleton
type Skeleton = `::${string}`;An ICU skeleton, accepted as a style by every format macro. See
Skeletons.
Next
- Runtime concepts, how to use these APIs in practice
- API: @saykit/react
- API: @saykit/carbon