type
ParamKind
export type ParamKind = "string" | "number" | "boolean" | "date";What a message says it needs.
API reference
Type-safe internationalisation on MessageFormat 2: a message's arguments are checked at the call, part of the Unified Toolchain for Flow.
Written from the source by uf doc when this site was built: the signature and the comment above each export, grouped by the specifier a program imports it from.
@uniflowed/i18n/cataloguetype
ParamKindexport type ParamKind = "string" | "number" | "boolean" | "date";What a message says it needs.
opaque-type
Paramexport opaque type Param<out TValue>: ParamCarrier<TValue> = ParamCarrier<TValue>;A parameter's kind, carrying the type of the value it stands for.
type
ParamMapexport type ParamMap = { readonly [string]: Param<mixed>, ... };The parameters of a message: what message takes beside its source.
type
ParamValueexport type ParamValue<TParam> = TParam extends Param<infer TValue> ? TValue : empty;The value type behind one parameter.
type
ParamArgsexport type ParamArgs<TParams extends ParamMap> = {
[Key in keyof TParams]: ParamValue<TParams[Key]>,
};The argument object a set of parameters describes.
variable
stringexport const string: Param<string> = { kind: "string", value: phantom };A parameter formatted as text: {$name}, or {$name :string}.
variable
numberexport const number: Param<number> = { kind: "number", value: phantom };A parameter formatted as a number: {$count :number}, {$n :integer}.
variable
booleanexport const boolean: Param<boolean> = { kind: "boolean", value: phantom };A parameter that selects but rarely prints: .match $isAdmin.
variable
dateexport const date: Param<Date> = { kind: "date", value: phantom };A parameter formatted as an instant: {$at :date}, :time, :datetime.
opaque-type
Messageexport opaque type Message<out TArgs>: MessageCarrier<TArgs> = MessageCarrier<TArgs>;One message: its MF2 source, parsed, and the arguments formatting it needs.
Opaque so that the only way to get one is [message], which is the only thing that checks the source against the parameters. A hand-written object of the same shape would be a message whose type says one thing and whose text says another, which is the single failure this module exists to prevent.
type
ArgsOfexport type ArgsOf<TMessage> = TMessage extends Message<infer TArgs> ? TArgs : empty;The arguments one message needs.
type
MessageMapexport type MessageMap = { readonly [string]: Message<mixed>, ... };A catalogue's messages: the object defineCatalogue is given.
type
Translationsexport type Translations<TMessages extends MessageMap> = {
[Key in keyof TMessages]: string,
};One locale's text for every key, as plain strings a translator can edit.
class
MessageContractErrorexport class MessageContractError extends Error { ... }A message that does not agree with the parameters declared beside it.
Separate from MessageSyntaxError because the message parses fine: this is the check no type system performs, and an error that says "unexpected token" would send the reader looking for a typo in the syntax.
function
messageexport function message<TParams extends ParamMap>(
source: string,
params: TParams,
): Message<ParamArgs<TParams>> { ... }Declare a message and the parameters formatting it needs.
Throws where the message is written if the two disagree — see the module header for why that is the point rather than a nicety.
type
Catalogueexport type Catalogue<TMessages extends MessageMap> = {
readonly locale: string,
/** Keys this locale did not translate, so they fall back to the source. */
readonly untranslated: $ReadOnlyArray<string>,
readonly t: <TKey extends $Keys<TMessages>>(key: TKey, args: ArgsOf<TMessages[TKey]>) => string,
/** The MF2 source behind a key, for a dev overlay or a test. */
readonly sourceOf: (key: string) => string,
/** What `translate` needs and nothing else should read. */
readonly messages: TMessages,
};An application's messages in one locale, and the typed way to format one.
A plain object rather than an opaque type: everything on it is worth reading, translate builds a second one from the first, and nothing breaks if an application builds one itself — the guarantee lives in [Message], which a catalogue can only hold and never mint.
function
defineCatalogueexport function defineCatalogue<TMessages extends MessageMap>(
locale: string,
messages: TMessages,
options?: CatalogueOptions,
): Catalogue<TMessages> { ... }Build a catalogue over a set of messages.
The messages are already parsed — [message] did that — so this allocates one lookup table and three empty caches. Defining a catalogue at module scope is cheap on purpose: the parse happened when the message was declared, and a page that never formats anything pays for nothing beyond it.
function
translateexport function translate<TMessages extends MessageMap>(
base: Catalogue<TMessages>,
locale: string,
translations: Partial<Translations<TMessages>>,
options?: CatalogueOptions,
): Catalogue<TMessages> { ... }A second locale over the same keys and the same argument types.
A translation is plain strings, which is what a translator can be handed and what a .json export can hold. It does not redeclare the parameters, because they are a property of the message rather than of the language, and a locale file that redeclared them would be a second place for them to disagree.
Partial on purpose: a locale that has translated nine keys of twenty is the ordinary state of a growing application, and refusing to build a catalogue for it would mean an untranslated string could never ship. The keys that fell back are on the catalogue as untranslated, so a test can require the list to be empty for the locales an application claims to support — which is the same fact, asserted by whoever wants to assert it rather than by this package.
type
LocaleLoaderexport type LocaleLoader<TMessages extends MessageMap> = () => Promise<
Partial<Translations<TMessages>>,
>;How a locale's translations arrive, usually () => import("./ja.js").
type
Localesexport type Locales<TMessages extends MessageMap> = {
readonly source: Catalogue<TMessages>,
readonly available: $ReadOnlyArray<string>,
readonly load: (locale: string) => Promise<Catalogue<TMessages>>,
};Every locale an application has, with only the source one loaded.
load is asynchronous and available is not, which is the split a server needs: negotiation happens against the list, before anything is fetched, so a request that resolves to a locale the page already has costs no round trip.
function
defineLocalesexport function defineLocales<TMessages extends MessageMap>(
source: Catalogue<TMessages>,
loaders: { readonly [string]: LocaleLoader<TMessages> },
options?: CatalogueOptions,
): Locales<TMessages> { ... }Bind a set of lazily loaded translations to a source catalogue.
The loaders are thunks rather than modules so that a bundler splits them: () => import("./ja.js") is a chunk a page fetches only if negotiation lands on Japanese, which is what "a page ships one locale" means in practice. Passing the modules directly would put every locale in the entry bundle and make this a table with extra steps.
A locale is loaded at most once. The promise is cached rather than the catalogue, so two concurrent requests for the same locale share one fetch instead of racing to build two catalogues over one download.
@uniflowed/i18n/formatclass
MessageFormatErrorexport class MessageFormatError extends Error { ... }A value that could not be formatted, and the fallback that was used instead.
Not thrown from this module directly: the catalogue's onError decides whether it is thrown at all.
type
FormatContextexport type FormatContext = {
readonly locale: string,
/**
* U+2068/U+2069 around every placeholder, per MF2's default bidi strategy.
*
* Off here, and the specification allows that — `bidiIsolation` is a
* formatting option with a `none` value for exactly this. The reason to
* default the other way from the specification is that uf's output goes into
* React children, where the DOM already isolates by direction from `dir` and
* the Unicode algorithm, and two invisible code points per placeholder would
* turn every `expect(t(…)).toBe("…")` in every application into a puzzle.
*
* A page that concatenates message output into a single text node with
* right-to-left content in it should turn this on.
*/
readonly bidiIsolation: boolean,
readonly onError: (error: MessageFormatError) => void,
readonly numberFormats: Map<string, MessageNumberFormat>,
readonly dateFormats: Map<string, MessageDateTimeFormat>,
readonly pluralRules: Map<string, MessagePluralRules>,
};What a catalogue hands formatMessage so that two calls can share work.
function
formatMessageexport function formatMessage(node: MessageNode, args: mixed, context: FormatContext): string { ... }Format one message.
The declarations run first and in order, because a .local may read what an earlier one produced; the body then sees a scope in which every declared name is already annotated.
@uniflowed/i18n/negotiatefunction
parseAcceptLanguageexport function parseAcceptLanguage(header: string): $ReadOnlyArray<string> { ... }Accept-Language as a list of tags, best first.
Entries with q=0 are dropped: RFC 9110 gives that the specific meaning "not acceptable", so treating it as merely last would pick a language the reader explicitly refused.
* is kept, as the tag *. It means "anything", and the only sensible answer to it is the fallback, which is what [negotiate] already returns when nothing matches — so it needs no special case there, only here, where dropping it would be wrong for a header that is nothing but *.
function
negotiateexport function negotiate(
requested: $ReadOnlyArray<string> | string,
available: $ReadOnlyArray<string>,
fallback: string,
): string { ... }The best of available for a reader who asked for requested.
requested is in preference order — what [parseAcceptLanguage] returns, or a single tag from a cookie or a URL segment. available is what the application has, and fallback is what it does when it has none of them.
The returned tag is one of available verbatim, case and all, rather than the folded form matching used — a caller is going to hand it to defineLocales, which keys on the string the application wrote.
@uniflowed/i18n/routingtype
LocaleRoutingexport type LocaleRouting<L extends string> = {|
readonly locales: $ReadOnlyArray<L>,
readonly locale: (params: { readonly [string]: mixed }) => L,
readonly staticParams: () => $ReadOnlyArray<{| locale: L |}>,
readonly middleware: (request: Request) => Response | null,
readonly metadata: (path?: string) => {|
alternates: {| languages: { [string]: string } |},
|},
|};The same locale union is used by middleware, pages and generated parameters.
function
createLocaleRoutingexport function createLocaleRouting<L extends string>(options: {|
readonly locales: $ReadOnlyArray<L>,
readonly defaultLocale: L,
readonly cookie?: string,
|}): LocaleRouting<L> { ... }A [locale] root segment. The router already knows how to enumerate dynamic routes and write their sitemap entries; this supplies the shared locale list. Negotiated redirects are private because a cookie can change their answer.
@uniflowed/i18n/syntaxtype
MessageLiteralexport type MessageLiteral = { readonly kind: "literal", readonly value: string };A quoted or unquoted literal: |two words|, 42, percent.
type
MessageVariableexport type MessageVariable = { readonly kind: "variable", readonly name: string };A reference to an argument or to something .input/.local declared.
type
MessageOperandexport type MessageOperand = MessageLiteral | MessageVariable;What a placeholder or an option may be given.
type
MessageOptionexport type MessageOption = {
readonly name: string,
readonly value: MessageOperand,
};One name=value inside a function annotation.
type
MessageAnnotationexport type MessageAnnotation = {
readonly name: string,
readonly options: $ReadOnlyArray<MessageOption>,
};:number, and the options it was given.
type
MessageExpressionexport type MessageExpression = {
readonly kind: "expression",
readonly operand: MessageOperand | null,
readonly annotation: MessageAnnotation | null,
/** Offset in the source, so a formatting error can point at it too. */
readonly at: number,
};One {…}.
operand is absent for an annotation-only expression such as {:datetime} in a .local, and annotation is absent for a bare {$name} — but never both, which the parser enforces rather than the type, because the type that says so is a union whose two arms are identical everywhere else and would be read at every use.
type
MessageTextexport type MessageText = { readonly kind: "text", readonly value: string };Literal text between placeholders, with escapes already resolved.
type
MessagePatternexport type MessagePattern = $ReadOnlyArray<MessagePart>;A run of text and placeholders: what actually gets formatted.
type
MessageDeclarationexport type MessageDeclaration = {
readonly kind: "input" | "local",
readonly name: string,
readonly expression: MessageExpression,
};.input {$count :number} or .local $n = {$count :number}.
One shape for both, because the difference is only where the value comes from — an .input re-annotates an argument under its own name, a .local introduces a new one — and every consumer treats them the same way.
type
MessageVariantKeyexport type MessageVariantKey =
| { readonly kind: "literal", readonly value: string }
| { readonly kind: "catch-all" };One key of one variant: a literal, or *.
type
MessageVariantexport type MessageVariant = {
readonly keys: $ReadOnlyArray<MessageVariantKey>,
readonly pattern: MessagePattern,
};One line of a .match: its keys, and what to format if they win.
type
MessageNodeexport type MessageNode = {
readonly declarations: $ReadOnlyArray<MessageDeclaration>,
readonly body: MessageBody,
};A parsed message: its declarations, and the body they feed.
class
MessageSyntaxErrorexport class MessageSyntaxError extends Error { ... }A message that is not MF2, or is MF2 uf does not implement.
Carries the offset as well as putting it in the text, so a caller that has the source — catalogue.js does, and names the key beside it — can point at the character rather than reprinting the sentence.
function
parseMessageexport function parseMessage(source: string): MessageNode { ... }Parse an MF2 message.
Throws [MessageSyntaxError] rather than returning a result, and that is a decision rather than an oversight: every caller in this package is catalogue.js building a catalogue at start-up, where there is nothing useful to do with a bad message except stop. A safeParse twin would exist for a tool that wants to report several at once, and nothing in uf is that tool yet.
type
MessageUsageexport type MessageUsage = {
/** Every variable it reads and did not declare itself: its parameters. */
readonly variables: $ReadOnlyArray<string>,
/** Every `:function` it names, so a caller can refuse ones it cannot run. */
readonly functions: $ReadOnlyArray<string>,
/** `["count", "number"]` for each annotation applied directly to a variable. */
readonly annotated: $ReadOnlyArray<[string, string]>,
};What a message asks of the outside world, read off the tree.
function
messageUsageexport function messageUsage(node: MessageNode): MessageUsage { ... }What a message needs, in one walk.
This is where the two halves of the promise this package makes are compared. Flow checks the *call* against the declared parameters; catalogue.js checks the declared parameters against this, which is the half a type system with no template-literal types cannot reach on its own — a message is a string literal, and the placeholders inside a string literal are not part of its type in any checker.
One walk and one return value rather than three functions, because all three facts are wanted at the same moment by the same caller, and a second walk is a second chance for the two to disagree about what a declaration shadows.