Translations (i18n)
Experimental The enrichment workflow works today; the generated file shape and the commands may still change between minor versions.
Your FriendlyText<T> map is the source language, so there is no separate message catalog. A translation is one optional file per locale with the same shape, and any blank string in it falls back to the source text.
Configuring Locales
List your locales in the tsconfig plugin entry that holds the rest of your configuration.
{
"compilerOptions": {
"plugins": [
{
"name": "mion",
"i18n": {
"sourceLocale": "en",
"locales": ["es", "pl", "pt-BR"]
}
}
]
}
}
Translation Workflow
Each locale runs the same enrichment loop: scaffold, fill the blanks, keep in sync.
# scaffold the Polish translation files; existing files are left alone
mion enrich --i18n pl
# re-sync after a change, keeping everything you wrote
mion enrich --i18n pl --update
# every configured locale in one go
mion enrich --i18n all --update
# CI gate: fail if any translation is missing, untranslated, or out of date
mion enrich --i18n all --require-complete
# clear the comments left by removed fields
mion enrich --i18n pl --prune
enrich --i18n --no-emit reports a missing file, unfilled blanks, an out of date translation, and leftovers waiting for a prune. By default these are warnings and the command succeeds: add --require-complete to fail that run, or set i18n.strict: true to fail every run. Rendering at runtime stays lenient either way.
Translation Files
Translations live under <genDir>/enriched/i18n/<locale>/, with the same paths as the friendly files. A regional tag like pt-BR works as a folder name.
src/.mion/enriched/
friendly/src/models/user.ts friendlyUser (the source language)
mock/src/models/user.ts mockUser (mock data is never translated)
i18n/pl/src/models/user.ts pl_friendlyUser (Polish)
i18n/pt-BR/src/models/user.ts pt_BR_friendlyUser (Brazilian Portuguese)
Translating a Map
Each locale file exports one FriendlyText<T> const per type, prefixed with the locale. The scaffold starts with every string blank and marked @todo, never a copy of your source text. Placeholder tokens work as in the source map, and a field in rt$default mode has one string to translate.
import type {FriendlyText} from '@mionjs/run-types';
import type {User} from './user';
// @todo: generated skeleton — fill in real data, then delete this line
export const pl_friendlyUser: FriendlyText<User> = {
rt$label: '',
rt$errors: {type: ''},
name: {
rt$label: '',
rt$errors: {
type: '',
minLength: {one: '', few: '', many: '', other: ''},
maxLength: {one: '', few: '', many: '', other: ''},
},
},
age: {
rt$label: '',
rt$errors: {
type: '',
min: {one: '', few: '', many: '', other: ''},
max: {one: '', few: '', many: '', other: ''},
},
},
isActive: {rt$label: '', rt$errors: {type: ''}},
tags: {
rt$label: '',
rt$errors: {type: ''},
rt$items: {rt$label: '', rt$errors: {type: ''}},
},
profile: {
rt$label: '',
rt$errors: {type: ''},
email: {
rt$label: '',
rt$errors: {
type: '',
minLength: {one: '', few: '', many: '', other: ''},
maxLength: {one: '', few: '', many: '', other: ''},
pattern: '',
},
},
score: {
rt$label: '',
rt$errors: {
type: '',
min: {one: '', few: '', many: '', other: ''},
max: {one: '', few: '', many: '', other: ''},
},
},
},
};
import type {FriendlyText} from '@mionjs/run-types';
import type {User} from './user';
export const pl_friendlyUser: FriendlyText<User> = {
rt$label: 'Konto użytkownika',
rt$errors: {type: ''},
name: {
rt$label: 'Imię i nazwisko',
rt$errors: {
type: '', // blank: renders 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: ''},
},
},
},
};
Keeping Translations in Sync
The friendly map and every locale file are generated from your type, never from each other. After a change, enrich --i18n pl --update adds a new message as a blank, keeps a removed one as a comment until --prune, and never loses what you wrote. Renaming a type renames its consts in every locale too.
Plural Forms per Locale
Each count rule (minLength, maxLength, min, max, lt, gt) is scaffolded with exactly the plural forms of that file's language: English gets one and other, Polish gets one, few, many and other. Other rules (type, pattern, and the rest) stay a single string.
// in the English source map
minLength: {
one: '$[label] needs at least $[val] character',
other: '$[label] needs at least $[val] characters',
},
The form is picked per locale with the built-in plural rules (Intl.PluralRules). Only other is required: a form you leave out or blank uses it, and if other is blank too, the source is used. A plain string also works when one wording fits every count.
$[val] follow the 3, never the length of the received text.Rendering Translated Messages
createFriendlyTextI18n is the locale-aware createFriendlyText: pass the source map and the translations to bundle, and you get the same renderer.
import {
createFriendlyTextI18n,
createGetValidationErrorsFn,
} from '@mionjs/run-types';
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
Only the locales you import end up in your bundle. If your source maps are not in English, pass sourceLocale (default en) so their plurals use the right rules.
Switching the Locale at Runtime
locale also accepts any object with a string value, such as a Vue ref. The renderer itself is not reactive: call errors() on each render, or wrap the call in computed().
import {createFriendlyTextI18n} from '@mionjs/run-types';
import type {User} from './user';
import {friendlyUser} from './friendly-user';
import {pl_friendlyUser} from './i18n-pl';
// 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
Locale Matching
The renderer tries the exact tag, then trims it (pt-BR finds pt), then any tag with the same base language (zh-Hant finds zh-Hans). If nothing matches, it uses the source map. A missing translation never throws, and resolveLocale(locale, translations) exports the same logic.
Money in Messages
To show a money limit, declare the field with the Currency format and set currency on the renderer, not in the type or the template. Any ISO code works (EUR, USD, PLN), and like locale it can be a {value} ref you switch at runtime.
import type * as TF from '@mionjs/run-types/formats';
export interface Order {
total: TF.Currency<{max: 10000}>;
}
import type {FriendlyText} from '@mionjs/run-types';
import {createFriendlyTextI18n} from '@mionjs/run-types';
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 broken max renders as "10.000,00 €" in German
$[val] then renders the amount for the active locale. Without currency it renders a plain localized number and never guesses a symbol. An app with one fixed currency can write the symbol into its templates and keep a plain number type.
Dates in Messages
Date limits need no setup. A field with any date format (a date string, a native Date bound, a Temporal type) renders its $[val] as a date in the active locale. A relative limit like now-P1D stays as written.