Type-safe internationalization for TypeScript. Define your translation catalogs once, and get localized messages and throwable exceptions, all validated at compile time.
Internationalization is often treated as an afterthought, strings scattered across files, parameters interpolated by hand, no type safety.
This package takes a different approach: you declare structured catalogs with entry(), and the compiler does the rest.
Parameters, locales, HTTP statuses, if something's wrong, you'll know before your code even runs.
It also plays nicely with @clov-std/error. Exception catalogs produce throwable exceptions that carry their own
translations and error key, so your error handling stays consistent: declare a status on an entry and you get a
LocalizedHttpException ready for an API response, omit it and you get a plain LocalizedException for
applications with no HTTP layer.
entry().defineLocaleNegotiator turns a raw Accept-Language header into one of your catalog locales, quality values included.defineExceptionCatalog gives you factory functions that create throwable exceptions, with a status code when the entry declares one.defineMessageCatalog does the same for plain messages : confirmations, notifications, anything that isn't an error.{{param}} placeholders in translations; resolveMessage fills them in.code, inherited from @clov-std/error, so handlers branch on error.code.bun add @clov-std/i18n
Peer dependency:
@clov-std/errormust be installed alongside.
entry() is the building block. Give it a status and it becomes an exception entry; leave status out and it's a plain message entry.
import { entry } from '@clov-std/i18n';
// This will produce a LocalizedHttpException when used in an exception catalog
const unauthorized = entry({
status: 'UNAUTHORIZED',
translations: {
en: 'Invalid credentials',
fr: 'Identifiants invalides'
}
});
// This will produce a plain LocalizedMessage when used in a message catalog.
// The `{{name}}` param is inferred from the template, no type argument needed.
const welcome = entry({
translations: {
en: 'Welcome, {{name}}!',
fr: 'Bienvenue, {{name}} !'
}
});
Group related exception entries into a catalog. Each key becomes a factory function you can call to throw a localized exception, and is used as the exception's error key.
import { defineExceptionCatalog, entry } from '@clov-std/i18n';
const AUTH_ERRORS = defineExceptionCatalog({
defaultLocale: 'en',
definitions: {
invalidCredentials: entry({
status: 'UNAUTHORIZED',
translations: {
en: 'Invalid credentials',
fr: 'Identifiants invalides'
}
}),
emailTaken: entry({
status: 'CONFLICT',
translations: {
en: 'Email "{{email}}" is already taken',
fr: 'L\'email "{{email}}" est dΓ©jΓ utilisΓ©'
}
})
}
});
// Throws a LocalizedHttpException with status 401
throw AUTH_ERRORS.invalidCredentials();
// Params are inferred from the `{{email}}` placeholder, type-checked so you can't forget one
throw AUTH_ERRORS.emailTaken({ email: 'user@example.com' });
Omit status and the entry produces a plain LocalizedException instead, for a worker, a CLI, or any
application that has no HTTP status to return. Both kinds can live in the same catalog, and the return
type of each factory tells you which one you get.
const JOB_ERRORS = defineExceptionCatalog({
defaultLocale: 'en',
definitions: {
cancelled: entry({
translations: {
en: 'Job {{id}} was cancelled',
fr: 'La tΓ’che {{id}} a Γ©tΓ© annulΓ©e'
}
})
}
});
// LocalizedException: same key, same translations, no httpStatusCode
throw JOB_ERRORS.cancelled({ id: '42' });
Same idea, but for things that aren't errors, success confirmations, notifications, labels, etc.
import { defineMessageCatalog, entry } from '@clov-std/i18n';
const DNS_MESSAGES = defineMessageCatalog({
defaultLocale: 'en',
definitions: {
recordCreated: entry({
translations: {
en: 'DNS record created successfully',
fr: 'Enregistrement DNS créé avec succès'
}
})
}
});
const msg = DNS_MESSAGES.recordCreated();
Locale consistency is enforced at compile time. Every entry in a catalog must cover the same set of locales - if one entry declares
de, they all must, ortscerrors on the entry that's missing it. No more silently untranslated locales.
resolveMessage takes a LocalizedException (HTTP or not) or a LocalizedMessage and returns the interpolated string for the locale you want.
import { resolveMessage } from '@clov-std/i18n';
const error = AUTH_ERRORS.emailTaken({ email: 'a@b.com' });
resolveMessage(error); // default locale β "Email "a@b.com" is already taken"
resolveMessage(error, 'fr'); // β "L'email "a@b.com" est dΓ©jΓ utilisΓ©"
resolveMessage expects a single locale, and a raw Accept-Language header is not one:
fr-CA,fr;q=0.9,en;q=0.8 matches no catalog key, so every message silently falls back to the
default locale. defineLocaleNegotiator closes that gap.
import { defineLocaleNegotiator } from '@clov-std/i18n';
const negotiateLocale = defineLocaleNegotiator({
supported: ['en', 'fr', 'it'],
fallback: 'en'
});
negotiateLocale('fr-CA,fr;q=0.9,en;q=0.8'); // β 'fr'
negotiateLocale('it;q=0.5,fr;q=0.9'); // β 'fr', quality values beat header order
negotiateLocale('fr;q=0,it'); // β 'it', q=0 explicitly refuses a locale
negotiateLocale('ja'); // β 'en', nothing acceptable
negotiateLocale(); // β 'en'
Define it once, then feed it to resolveMessage wherever a request is handled.
const lang = negotiateLocale(request.headers.get('accept-language') ?? undefined);
resolveMessage(error, lang);
The return type is narrowed to 'en' | 'fr' | 'it', so a locale none of your catalogs cover
can't reach resolveMessage in the first place.
Keep
supportedlowercase. Ranges are matched case-insensitively against it, each one as a whole tag (pt-br) before its language subtag (pt).
Responses that vary by locale need
Vary: Accept-Language. Without it a shared cache keys on the URL alone and will serve one visitor's language to the next.
Full docs: https://clovlabs.github.io/std/
MIT - Feel free to use it.