Type-check translation keys
Generate a TranslationKeys declaration from your catalogs so a misspelled key, a missing
parameter or a wrong parameter type is a TypeScript error in your editor. Any instance from the
Quick start qualifies; the comvi typegen path also needs a Comvi
project.
Generate from local JSON
Section titled “Generate from local JSON”npm install -D @comvi/vite-pluginimport { defineConfig } from 'vite';import { comviTypes } from '@comvi/vite-plugin';
export default defineConfig({ plugins: [comviTypes({ translations: './src/locales', output: './src/types/i18n.d.ts' })],});import { createI18n, icuCompiler, loader } from '@comvi/react';import './types/i18n';
export const i18n = createI18n({ locale: 'en', fallbackLocale: 'en', compiler: icuCompiler }).use( loader({ en: () => import('./locales/en.json') }),);comviTypes() reads every .json file under translations, infers parameter names and
types from the {param} and ICU arguments in the messages, and writes the declaration to
output on buildStart and whenever a file changes. No account and no network: the catalogs
in your repository are the schema. A root en.json is the default namespace and admin/en.json
is the admin namespace; defaultNs and fileTemplate change that layout, on
Vite plugin. Import the generated file once, in the module
that builds the instance, and every t() in the app is typed; on Nuxt, import it from comvi.setup.ts.
Without Vite, InferKeys derives the key set from an imported JSON object with no generation
step:
import type { InferKeys } from '@comvi/core';import en from '../locales/en.json';import admin from '../locales/en/admin.json';
declare module '@comvi/core' { interface TranslationKeys extends InferKeys<typeof en>, InferKeys<typeof admin, 'admin'> {}}
export {};InferKeys<T, NS?, DefaultNS?> flattens nested objects to dot keys and prefixes every key with
NS unless it equals DefaultNS. It needs resolveJsonModule in tsconfig.json, types every
parameter as optional and stops five levels deep, so for parameter types prefer the plugin.
The snippet imports from @comvi/core. An app that installed only a binding adds
@comvi/core to its own dependencies first: it arrives as a transitive dependency, and
pnpm does not hoist one into the app’s node_modules, so the import does not resolve there.
Generate from a Comvi project
Section titled “Generate from a Comvi project”npm install -D @comvi/clinpx comvi typegenimport { createI18n, icuCompiler } from '@comvi/react';import { cdnLoader } from '@comvi/plugin-fetch-loader';import './types/i18n';
export const i18n = createI18n({ locale: 'en', fallbackLocale: 'en', compiler: icuCompiler }).use( cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }),);comvi typegen reads .comvirc.json, fetches the key schema from your project and writes
src/types/i18n.d.ts, the same file the Vite plugin writes. It needs a project from
Connect to Comvi and an API key: set COMVI_API_KEY, or apiKey in
.comvirc.json, and the key selects the project. The schema is the keys the platform holds, so
the file is current when a translator adds a key that no local catalog has yet.
- run: npx comvi pull- run: npx comvi typegen --check- run: npx tsc --noEmit--check exits 0 when the file is current, 1 when it is outdated, 2 when the check could
not run and 4 on an invalid config or a schema that cannot be emitted. --watch subscribes to
the project’s schema over SSE and regenerates on every change upstream; it does not watch local
files. A predev script running comvi pull && comvi typegen keeps the file fresh during
development. Every flag is on comvi typegen.
Generated type structure
Section titled “Generated type structure”{ "hello": "Hello!", "greeting": "Welcome, {name}!", "cart.items": "{count, plural, =0 {No items} one {# item} other {# items}}"}// Import to ensure module is resolved before augmentationimport '@comvi/react';
declare module '@comvi/react' { interface TranslationKeys { "cart.items": { "count": number }; "dashboard:page.title": never; "dashboard:stats.users": { "count": number }; "greeting": { "name": string }; "hello": never; }}
export {};Each key maps to its required parameters: never means none, { name: string } means a
required string, and a plural count is a number. Keys outside the default namespace carry a
namespace: prefix. Keys are sorted in code-unit order and every name goes through
JSON.stringify, so regenerating an unchanged schema produces no diff.
Typed calls
Section titled “Typed calls”import { useI18n } from '@comvi/react';
const { t } = useI18n();
t('hello');t('greeting', { name: 'Alice' });t('cart.items', { count: 5 });t('page.title', { ns: 'dashboard' });t('stats.users', { ns: 'dashboard', count: 5 });useI18n('dashboard') narrows t() to that namespace and { ns: 'dashboard' } names it per
call; both are checked. t('dashboard:page.title') is not a shorthand: the key union holds bare
keys only, and at runtime that string is looked up as a literal key.
For a key built at runtime, map the variable to literal keys so the map itself is checked:
import '@comvi/core';import { useI18n } from '@comvi/react';
declare module '@comvi/core' { interface TranslationKeys { 'order.status.pending': never; 'order.status.shipped': never; }}
type TranslationKey = keyof import('@comvi/core').TranslationKeys;
const statusKeys = { pending: 'order.status.pending', shipped: 'order.status.shipped',} as const satisfies Record<string, TranslationKey>;
declare const order: { status: 'pending' | 'shipped' };const { t } = useI18n();t(statusKeys[order.status]);The same declare module block adds keys you register at runtime, from a CMS or an
experiment, next to the generated ones.
Read the type errors
Section titled “Read the type errors”import '@comvi/core'; // resolves the module before augmenting it
declare module '@comvi/core' { interface TranslationKeys { welcome: { name: string }; hello: never; 'errors:NOT_FOUND': never; }}import { createI18n } from '@comvi/core';
const i18n = createI18n({ locale: 'en' });
i18n.t('typo', { name: 'Alice' }); // unknown keyi18n.t('NOT_FOUND'); // a key of another namespace, written barei18n.t('welcome', { age: 5 }); // the wrong parameterserror TS2345: Argument of type '"typo"' is not assignable to parameter of type '"welcome" | "hello" | null'.error TS2345: Argument of type '{ age: number; }' is not assignable to parameter of type 'CallParams<{ name: string; }, {}>'. Property 'name' is missing in type '{ age: number; }' but required in type 'MissingParams<{ name: string; }, {}>'.t() has one call signature, so TypeScript names the actual problem on the first line. An
unknown key is reported against the union of keys this call site may use; the trailing null
is the no-op that returns "". A key of another namespace joins that union once you write
{ ns: 'errors' } or reach t() through useI18n('errors'); the qualified
t('errors:NOT_FOUND') is in the union nowhere. For wrong parameters the second line
names what is missing: CallParams is the full params object for the key, MissingParams the
part the call site still has to supply, and a defaultParams entry moves a param from the
second to the first. A wrong value, as opposed to a missing one, is a plain TS2322.
import { createI18n } from '@comvi/core';
const i18n = createI18n({ locale: 'en' });
i18n.t('welcome');error TS2554: Expected 2 arguments, but got 1.The params object is required for a key that declares params, so a forgotten one is an
argument-count error. A key declared never makes the second argument optional.
import { useI18n } from '@comvi/react';
export function Refresh() { const { reloadMessages } = useI18n(); void reloadMessages();}error TS2339: Property 'reloadMessages' does not exist on type 'UseI18nReturn<{}, undefined>'.reloadMessages, addActiveNamespace, onLoadError and onMissingKey live on
useI18nLoader() and useI18nPlugins(), not on useI18n(), so destructuring one off the bag
is a missing-property error. A member that is missing at runtime means the instance never
composed the capability, and useI18nLoader() is the fix there too.
i18n.use(localeDetectorPlugin({ order: ['navigator'] })); // wrong: a plugin, not an installer[@comvi/core] .use(f) expects an installer that returns the i18n instance it was given, but f returned function. A raw plugin goes through .use(definePlugin(f)) or registerPlugin(f); a plugin package's installer is the name WITHOUT the Plugin suffix (localeDetector(), not localeDetectorPlugin()).A raw plugin factory in the .use(…) slot compiles under any and in plain JavaScript, so it
is a runtime error: localeDetector() is the installer, localeDetectorPlugin() builds a
plugin. The thrown error carries code: "E_USE_NOT_INSTALLER", and a production build throws
that bare string.
How it works
Section titled “How it works”@comvi/core declares an empty interface named TranslationKeys, and t() derives its key
type from it. TypeScript merges every declare module block that names the same interface, so
the generated file adds members to the interface the library already reads. While the interface
is empty, keyof TranslationKeys is never and t() falls back to accepting any string,
which is why an untyped app compiles with no setup. The first key you add removes string
from the union, and from then on t() accepts only the keys in the interface. The second
parameter follows the first: a key mapped to never takes no params, a key mapped to
{ name: string } requires that object, and a plural count is number because that is what
the compiler inspects at render.
Both tools augment the Comvi package your package.json declares, because that is the one
module specifier your app can resolve: @comvi/core when it is a dependency, otherwise the
first of @comvi/react, @comvi/next, @comvi/vue, @comvi/nuxt, @comvi/svelte and
@comvi/solid. Every binding re-exports the same interface from @comvi/core, so a
declare module '@comvi/react' block lands in the interface t() reads whichever package the
app imports from. A project that declares none is refused, because a declare module for a
package TypeScript cannot resolve would declare a new ambient module instead of merging. The
import '@comvi/react' line at the top of the generated file exists for the same reason. Set
augment, in .comvirc.json or the plugin options, to name another module such as a private
wrapper package.
Namespaced keys are stored as "ns:key" and filtered out of the plain t(key) union, so the
bare union holds the default namespace only. { ns: 'dashboard' } offers the keys under that
namespace with the prefix stripped, in every binding and on the instance, and the parameter
type is looked up under the prefixed name, so a wrong ns is a key error rather than a silent
miss. useI18n('dashboard') does the same for the t it returns in @comvi/react,
@comvi/vue, @comvi/svelte, @comvi/solid and @comvi/next/client. The auto-imported
useI18n('dashboard') of @comvi/nuxt takes a plain string and scopes the lookup at runtime
only, so pass { ns } there. The qualified form t('errors:NOT_FOUND') type-checks nowhere,
and at runtime it is looked up as a literal key with a colon in it, which no catalog has.
The union covers what a key literal can tell the compiler. Three shapes fall outside it and are
not checked: a key the call site could not narrow to one literal, Parameters<typeof t>, and
t handed to a (key: string) => string callback, each with its workaround on
Limitations. The types also
say nothing about which catalog is loaded or which locale has the key: a key in the interface
can still be missing at runtime in a locale the translator has not reached, and a key present
on the platform but absent from the generated file is a compile error until you regenerate.
Typegen is a check on your code against a schema, not on the catalog against the code.
Read next
Section titled “Read next”- Vite plugin:
fileTemplate,defaultNs,strictParamsandaugment. - comvi typegen: flags, the config file, check and watch modes.
- Split translations into namespaces: how a namespace narrows the key union.