Skip to content
Comvi

Type-check translation keys

View .md

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.

Terminal
npm install -D @comvi/vite-plugin
vite.config.ts
import { defineConfig } from 'vite';
import { comviTypes } from '@comvi/vite-plugin';
export default defineConfig({
plugins: [comviTypes({ translations: './src/locales', output: './src/types/i18n.d.ts' })],
});
src/i18n.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:

src/types/i18n.d.ts
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.

Terminal
npm install -D @comvi/cli
npx comvi typegen
src/i18n.ts
import { 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.

.github/workflows/ci.yml
- 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.

locales/en.json
{
"hello": "Hello!",
"greeting": "Welcome, {name}!",
"cart.items": "{count, plural, =0 {No items} one {# item} other {# items}}"
}
src/types/i18n.d.ts
// Import to ensure module is resolved before augmentation
import '@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.

src/typed.ts
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:

src/status.ts
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.

src/types/i18n.d.ts
import '@comvi/core'; // resolves the module before augmenting it
declare module '@comvi/core' {
interface TranslationKeys {
welcome: { name: string };
hello: never;
'errors:NOT_FOUND': never;
}
}
src/wrong-key.ts
import { createI18n } from '@comvi/core';
const i18n = createI18n({ locale: 'en' });
i18n.t('typo', { name: 'Alice' }); // unknown key
i18n.t('NOT_FOUND'); // a key of another namespace, written bare
i18n.t('welcome', { age: 5 }); // the wrong parameters
error 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.

src/missing-param.ts
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.

src/Refresh.ts
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.

@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.