Skip to content

Type-Safe Translations

Type-safe translations catch errors at build time instead of at runtime. Misspelled keys, missing parameters, and wrong parameter types become TypeScript errors — visible in your editor before anyone sees a broken translation in production.

Without types, translation errors are silent:

// These all compile fine, but fail at runtime
t('homee.title'); // Typo in key — returns "homee.title"
t('greeting'); // Missing required {name} param — shows "{name}"
t('cart.items', { cnt: 5 }); // Wrong param name — shows "{count}"

With type-safe translations, your editor catches all of these:

t('homee.title'); // Error: key does not exist
t('greeting'); // Error: missing required param 'name'
t('cart.items', { cnt: 5 }); // Error: 'cnt' is not assignable, did you mean 'count'?
  1. Install the Comvi CLI

    Terminal window
    npm install -D @comvi/cli
  2. Generate types from your Comvi project schema

    Terminal window
    npx comvi typegen

    This reads .comvirc.json, fetches the translation key schema from Comvi, and generates a TypeScript declaration file. By default, it outputs to src/types/i18n.d.ts.

  3. Wire types into your i18n instance

    The CLI generates a .d.ts file that augments @comvi/core’s TranslationKeys interface. When you import that file, all frameworks automatically gain type safety for t():

    src/i18n.ts
    import { createI18n } from '@comvi/react';
    import { FetchLoader } from '@comvi/plugin-fetch-loader';
    // Import the generated types to augment TranslationKeys
    import './types/i18n';
    export const i18n = createI18n({
    locale: 'en',
    fallbackLocale: 'en',
    })
    .use(FetchLoader({
    cdnUrl: 'https://cdn.comvi.io/your-project-id',
    }));
  4. Use t() with full autocomplete

    const { t } = useI18n();
    t('hello'); // OK — no params required
    t('greeting', { name: 'Alice' }); // OK — name is string | number
    t('cart.items', { count: 5 }); // OK — count is number
    t('greeting'); // Error: missing param 'name'
    t('typo.key'); // Error: key does not exist

The CLI parses your translation files and extracts ICU parameter types. Given these translation files:

locales/en.json
{
"hello": "Hello!",
"greeting": "Welcome, {name}!",
"cart.items": "{count, plural, =0 {No items} one {# item} other {# items}}"
}
locales/en/dashboard.json
{
"page.title": "Dashboard",
"stats.users": "{count, plural, one {# user} other {# users}} online"
}

The CLI generates a .d.ts that augments @comvi/core:

src/types/i18n.d.ts
import '@comvi/core';
declare module '@comvi/core' {
interface TranslationKeys {
'hello': never;
'greeting': { name: string };
'cart.items': { count: number };
'dashboard:page.title': never;
'dashboard:stats.users': { count: number };
}
}
export {};

How to read this:

  • Keys map to their required parameters
  • never means no parameters are needed
  • { name: string } means a name parameter is required
  • { count: number } means count is required and must be a number (used in plural)
  • Namespaced keys use the namespace:key format (keys in the default namespace have no prefix)

When you type t(', your editor suggests all valid keys:

t('h|')
// ^ autocomplete shows:
// hello
// greeting

When you select a key with parameters, your editor tells you what is required:

t('greeting', { | })
// ^ autocomplete shows:
// name: string

Namespaced keys can be translated by passing an explicit namespace:

const { t } = useI18n();
t('page.title', { ns: 'dashboard' });

Namespace support differs by framework. Solid has namespace-bound typing for useI18n('dashboard'). React, Vue, Nuxt, and Svelte accept a namespace at runtime, but their useI18n(ns) return types are not narrowed by that argument; use the explicit { ns: 'dashboard' } parameter when you need type-checked namespaced keys across all bindings.

const { t } = useI18n();
t('hello'); // OK — default namespace
t('page.title', { ns: 'dashboard' }); // OK — dashboard:page.title
t('stats.users', { ns: 'dashboard', count: 5 }); // OK — dashboard:stats.users

Run type generation in your CI pipeline to catch missing translations before they reach production:

.github/workflows/ci.yml
- name: Pull translations
run: npx comvi pull
- name: Generate translation types
run: npx comvi typegen
- name: Type check
run: npx tsc --noEmit

If a developer adds a t('new.key') call but the key does not exist in the translation files, the type check fails and blocks the build.

During development, regenerate types automatically when the project schema changes:

Terminal window
npx comvi typegen --watch

This subscribes to Comvi schema updates over SSE and regenerates the type definition whenever keys or parameters change upstream.

Sometimes you need to construct a key dynamically — for example, translating an enum value or building a key from a variable. TypeScript cannot validate dynamic strings, so you need to assert the type:

type TranslationKey = keyof import('@comvi/core').TranslationKeys;
// Option 1: Type assertion
const statusKey = `order.status.${order.status}` as TranslationKey;
t(statusKey);
// Option 2: Mapped lookup (safer)
const statusKeys = {
pending: 'order.status.pending',
shipped: 'order.status.shipped',
delivered: 'order.status.delivered',
} as const satisfies Record<string, TranslationKey>;
t(statusKeys[order.status]);

If you add translations at runtime (for example, from a CMS or A/B test), you can extend the generated types with module augmentation:

src/types/i18n.d.ts
declare module '@comvi/core' {
interface TranslationKeys {
'experiment.headline': { variant: string };
'experiment.cta': never;
}
}

This merges your custom keys into the TranslationKeys interface so t() recognizes them.

Terminal window
npx comvi typegen [options]
OptionDefaultDescription
--config, -c.comvirc.jsonPath to config file
--watchfalseWatch for file changes and regenerate (or watch TMS for schema changes)
--checkfalseCI mode: exit 1 if types are outdated

If your translations live in local files (not synced from the TMS), use the @comvi/vite-plugin for zero-config type generation:

Terminal window
npm install -D @comvi/vite-plugin
vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { comviTypes } from '@comvi/vite-plugin';
export default defineConfig({
plugins: [
vue(),
comviTypes({
translations: './src/locales',
output: './src/types/i18n.d.ts',
defaultNs: 'default',
}),
],
});

The plugin watches your translation directory and regenerates types on every save, with no CLI invocation needed.