# Type-check translation keys

Generate a TranslationKeys declaration from local JSON with the Vite plugin or from your Comvi project with comvi typegen, so t() autocompletes keys and checks parameters.

Canonical: https://comvi.io/docs/i18n/type-safety

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](/docs/i18n/quick-start) qualifies; the `comvi typegen` path also needs a Comvi
project.

## Generate from local JSON

`Terminal`

```bash
npm install -D @comvi/vite-plugin
```

`vite.config.ts`

```ts

export default defineConfig({
plugins: [comviTypes({ translations: './src/locales', output: './src/types/i18n.d.ts' })],
});
```

`src/i18n.ts`

```ts

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](/docs/i18n/plugins/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`

```ts

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

`Terminal`

```bash
npm install -D @comvi/cli
npx comvi typegen
```

`src/i18n.ts`

```ts

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](/docs/i18n/connect-comvi) and an [API key](/docs/platform/api-keys): 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`

```yaml
- 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](/docs/cli/typegen).

## Generated type structure

`locales/en.json`

```json
{
"hello": "Hello!",
"greeting": "Welcome, {name}!",
"cart.items": "{count, plural, =0 {No items} one {# item} other {# items}}"
}
```

`src/types/i18n.d.ts`

```ts
// Import to ensure module is resolved before augmentation

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.

> **Types and the compiler are two decisions**
>
> Typegen reads ICU syntax to derive parameter types whether or not the instance can render it.
> A catalog with `{count, plural, …}` still needs `compiler: icuCompiler` on the instance, or the
> key throws at ingestion in development and renders literally in production. See
> [Pluralize with ICU](/docs/i18n/pluralization).

## Typed calls

`src/typed.ts`

```ts

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`

```ts

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

`src/types/i18n.d.ts`

```ts

declare module '@comvi/core' {
interface TranslationKeys {
    welcome: { name: string };
    hello: never;
    'errors:NOT_FOUND': never;
}
}
```

`src/wrong-key.ts`

```ts

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
```

```text
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`

```ts

const i18n = createI18n({ locale: 'en' });

i18n.t('welcome');
```

```text
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`

```ts

export function Refresh() {
const { reloadMessages } = useI18n();
void reloadMessages();
}
```

```text
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.

```js
i18n.use(localeDetectorPlugin({ order: ['navigator'] })); // wrong: a plugin, not an installer
```

```text
[@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

`@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](/docs/i18n/limitations#typed-keys-three-shapes-t-does-not-check). 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

- [Vite plugin](/docs/i18n/plugins/vite-plugin): `fileTemplate`, `defaultNs`, `strictParams` and `augment`.
- [comvi typegen](/docs/cli/typegen): flags, the config file, check and watch modes.
- [Split translations into namespaces](/docs/i18n/namespaces): how a namespace narrows the key union.
