Translations (i18n)
Your FriendlyText<T> map already holds every label and error message for a type, written in one language. Translations start from that fact: the friendly map you wrote is the source language. There is no separate message catalog to maintain.
A translation is an optional file with the exact same shape, one per locale. Every string in it is optional too: anything left blank falls back to the source text, string by string. A half-translated app never breaks and never shows a raw key; the untranslated pieces simply render in the source language until someone fills them in.
The workflow
List your locales once, in the same tsconfig plugin entry that holds the rest of your configuration:
{
"compilerOptions": {
"plugins": [
{
"name": "ts-runtypes",
"i18n": {
"sourceLocale": "en",
"locales": ["es", "pl", "pt-BR"]
}
}
]
}
}
From there it is the same loop as the rest of the enrichment workflow: scaffold a locale, fill the blanks (yourself, or the agent), and keep it in sync as things change:
# scaffold the Polish translation files (create-only, existing files are left alone)
ts-runtypes enrich --i18n pl
# after a type or the source text changes: re-sync, keeping everything you wrote
ts-runtypes enrich --i18n pl --update
# every configured locale in one go
ts-runtypes enrich --i18n all --update
# CI gate: fail if any translation is missing, untranslated, or out of date
ts-runtypes enrich --i18n all --require-complete
# clear parked comments left behind by removed fields
ts-runtypes enrich --i18n pl --prune
enrich --i18n --no-emit reports a missing translation file, unfilled blanks, a translation that is out of date against its source map, and parked leftovers awaiting a prune. By default the unfinished ones are warnings and the command still succeeds, so a routine check does not block on translations you have not gotten to yet. To make them fail CI, either add --require-complete for that one run or set i18n.strict: true to make it the default for the project. Rendering at runtime stays lenient either way.
What a translation file looks like
Translations live under <genDir>/enriched/i18n/<locale>/, mirroring your source tree the same way the friendly files do. The locale is a folder name, so a regional tag like pt-BR works as-is:
src/__runtypes/enriched/
friendly/models/user.ts friendlyUser (the source language)
mock/models/user.ts mockUser (mock data is never translated)
i18n/pl/models/user.ts pl_friendlyUser (Polish)
i18n/pt-BR/models/user.ts pt_BR_friendlyUser (Brazilian Portuguese)
Each file exports one const per type, prefixed with the locale and typed FriendlyText<T>, exactly like the source map. There is no separate translation type: a locale file is a friendly map, written in another language. Every file is generated from your type directly, so a scaffold arrives with every string blank and marked @todo. It never copies your source text in as if it were already translated:
import type {FriendlyText} from '@ts-runtypes/core';
import type {User} from './user';
// src/__runtypes/enriched/i18n/pl/models/user.ts — scaffolded by `enrich --i18n pl`:
// the same tree as the source map, every leaf blank, plural arms in POLISH form.
export const pl_friendlyUser: FriendlyText<User> = {
rt$label: '', // @todo
rt$errors: {type: ''}, // @todo
name: {
rt$label: '', // @todo
rt$errors: {
type: '', // @todo
minLength: {one: '', few: '', many: '', other: ''}, // @todo
maxLength: {one: '', few: '', many: '', other: ''}, // @todo
},
},
age: {
rt$label: '', // @todo
rt$errors: {type: '', min: {one: '', few: '', many: '', other: ''}, max: {one: '', few: '', many: '', other: ''}}, // @todo
},
isActive: {rt$label: '', rt$errors: {type: ''}}, // @todo
tags: {rt$label: '', rt$errors: {type: ''}, rt$items: {rt$label: '', rt$errors: {type: ''}}}, // @todo
profile: {
rt$label: '', // @todo
rt$errors: {type: ''}, // @todo
email: {
rt$label: '',
rt$errors: {
type: '',
minLength: {one: '', few: '', many: '', other: ''},
maxLength: {one: '', few: '', many: '', other: ''},
pattern: '',
},
}, // @todo
score: {
rt$label: '',
rt$errors: {type: '', min: {one: '', few: '', many: '', other: ''}, max: {one: '', few: '', many: '', other: ''}},
}, // @todo
},
};
Fill what you can, leave the rest blank:
import type {FriendlyText} from '@ts-runtypes/core';
import type {User} from './user';
// Fill what you can, leave the rest blank — a blank leaf keeps rendering in
// the source language.
export const pl_friendlyUser: FriendlyText<User> = {
rt$label: 'Konto użytkownika',
rt$errors: {type: ''},
name: {
rt$label: 'Imię i nazwisko',
rt$errors: {
type: '', // still blank: this one keeps rendering in the source language
minLength: {
one: '$[label] musi mieć co najmniej $[val] znak',
few: '$[label] musi mieć co najmniej $[val] znaki',
many: '$[label] musi mieć co najmniej $[val] znaków',
other: '$[label] musi mieć co najmniej $[val] znaku',
},
maxLength: {one: '', few: '', many: '', other: ''},
},
},
age: {
rt$label: 'Wiek',
rt$errors: {type: '', min: {one: '', few: '', many: '', other: ''}, max: {one: '', few: '', many: '', other: ''}},
},
isActive: {rt$label: '', rt$errors: {type: ''}},
tags: {rt$label: 'Tagi', rt$errors: {type: ''}, rt$items: {rt$label: '', rt$errors: {type: ''}}},
profile: {
rt$label: 'Profil',
rt$errors: {type: ''},
email: {
rt$label: 'Adres e-mail',
rt$errors: {type: '', minLength: '', maxLength: '', pattern: 'Podaj prawidłowy adres e-mail'},
},
score: {
rt$label: '',
rt$errors: {type: '', min: {one: '', few: '', many: '', other: ''}, max: {one: '', few: '', many: '', other: ''}},
},
},
};
All the placeholder tokens work exactly as they do in the source map, and a field that uses the rt$default mode has exactly one string to translate.
Translations stay in sync the same way the source maps do, because they sync from the same place: your type. The friendly map and every locale file are each generated straight from the source type, so no generated file ever feeds another and a stale friendly map can never mislead a translation. Run enrich --i18n pl --update after a change: a new message arrives as a fresh blank, a removed one is parked as a comment (cleared by --prune), and nothing you wrote is ever lost. Renaming a type in your code renames its consts across every locale too.
Plurals
A message that carries a count often needs different wording at different counts. English gets away with two forms (1 character, 3 characters); Polish needs four, Arabic six. You should not have to know any of that, so the generator handles it.
Every count-carrying rule (minLength, maxLength, min, max, lt, gt) is scaffolded as a small object with exactly the plural forms that file's language uses, and you only fill in strings. An English map gets one and other; the Polish translation of the same rule gets one, few, many and other, as in the example above. Every other rule (type, pattern, and the rest) stays a single string:
// in the English source map
minLength: {
one: '$[label] needs at least $[val] character',
other: '$[label] needs at least $[val] characters',
},
At render time the right form is picked with the browser's own plural rules (Intl.PluralRules), per locale. other is the one required form and covers any form you leave out; unused forms can be pruned, and a plain string is still fine on a count-carrying rule when one wording covers every count.
$[val]) follow the 3, never the length of the received text. That way "needs at least $val characters" always agrees with its own number. Write each form for the limit it will display.One more subtlety handled for you: a plural is translated as a whole. A half-filled translated plural degrades to its own other form first, and only a fully blank one falls back to the source; the renderer never mixes one language's forms with another's inside a single message.
Rendering translated messages
createFriendlyTextI18n is the locale-aware version of createFriendlyText. Hand it the source map plus the translations you want to bundle, and it returns the same renderer, with the same label and errors methods:
import {createFriendlyTextI18n, createGetValidationErrorsFn} from '@ts-runtypes/core';
import type {User} from './user';
import {friendlyUser} from './friendly-user';
import {es_friendlyUser} from './i18n-es';
import {pl_friendlyUser} from './i18n-pl';
const getUserErrors = createGetValidationErrorsFn<User>();
const friendly = createFriendlyTextI18n<User>(friendlyUser, {
locale: 'pl',
translations: {es: es_friendlyUser, pl: pl_friendlyUser},
});
const badInput: unknown = {name: 'A', age: 200};
friendly.errors(getUserErrors(badInput));
// → Polish messages, falling back to your source text wherever a blank was left
Translations are plain imports, so only the locales you import end up in your bundle. Two details worth knowing:
Live locale switching. locale also accepts any object with a string value property, such as a Vue ref. It is read again on every render, so flipping it changes the language of the next errors() call with nothing to rebuild:
import {createFriendlyTextI18n} from '@ts-runtypes/core';
import type {User} from './user';
import {friendlyUser} from './friendly-user';
import {pl_friendlyUser} from './i18n-pl';
// any {value} ref works — a plain object here, a Vue ref in a Vue app
const locale = {value: 'en'};
export const friendly = createFriendlyTextI18n<User>(friendlyUser, {
locale, // read again on every render
translations: {pl: pl_friendlyUser},
});
locale.value = 'pl'; // the next errors() call renders in Polish
The renderer itself is not reactive: call errors() per render, or wrap the call in a computed().
Locale matching. The active locale does not need an exact match. Ask for pt-BR with only a pt translation on hand and the pt file is used; the renderer tries the exact tag first, then trims it toward the base language, and falls back to the source map when nothing matches. The same logic is exported as resolveLocale(locale, translations) if you want to reuse it. A missing translation never throws.
If your source maps are not written in English, pass sourceLocale (default en, matching the tsconfig default) so plurals rendered from the source map use the right rules.
Money and dates in messages
A limit shown in a message is sometimes money or a date. You never mark that in the template: the type already says what the value is, and $[val] renders it correctly for the active locale.
For monetary amounts, declare the field with the Currency format instead of a plain number:
import type * as TF from '@ts-runtypes/core/formats';
export interface Order {
total: TF.Currency<{max: 10000}>;
}
Then tell the renderer which currency your app uses. Any ISO code works (EUR, USD, PLN), and like the locale it can be a {value} ref you switch at runtime:
import type {FriendlyText} from '@ts-runtypes/core';
import {createFriendlyTextI18n} from '@ts-runtypes/core';
import type {Order} from './i18n-currency-type';
const friendlyOrder: FriendlyText<Order> = {
rt$label: 'Order',
rt$errors: {type: ''},
total: {rt$label: 'Total', rt$errors: {type: '', max: 'at most $[val]'}},
};
const de_friendlyOrder: FriendlyText<Order> = {
rt$label: 'Bestellung',
rt$errors: {type: ''},
total: {rt$label: 'Summe', rt$errors: {type: '', max: 'höchstens $[val]'}},
};
export const friendly = createFriendlyTextI18n<Order>(friendlyOrder, {
locale: 'de',
translations: {de: de_friendlyOrder},
currency: 'EUR',
});
// a violated max renders as "10.000,00 €" in German and "$10,000.00" in English:
// symbol, separators and decimals all follow the locale and the currency
Which currency a value is in is data your app owns, so it lives in this one renderer option, never in the type and never in the template. If you do not pass one, the limit renders as a plain localized number and a symbol is never guessed. An app with a single fixed currency can also simply write the symbol into its templates and keep a plain number type; the Currency format is for when you want the browser to place symbols and decimals correctly per language.
Date limits need no setup at all. A field typed with any of the date formats (a date string, a native Date bound, a Temporal type) renders its $[val] as a real date in the active locale. A relative limit like now-P1D stays as written, since it is not a fixed point in time.
There is nothing else to configure: no format tables and no extra template syntax. The type says what the value is; the reader's locale says how to write it.