Typed translation keys across packages
t('chekout.submit') compiles, ships and renders a raw key in production. The typo is one character, it is in a string literal, and nothing in the toolchain has any opinion about string literals.
In a single application that is an annoyance. In a workspace it is worse, because the key may be defined in a package the author never opens and removed by a team the author never talks to. Generating a key type from the catalogue turns both cases into compile errors.
Root cause: keys are strings, and strings are opaque
Every translation API takes a string. That is a reasonable design — it keeps the library independent of your catalogue — and it means the compiler has nothing to check against. A misspelling, a stale key, a key that belongs to a namespace this component did not load: all of them are valid strings.
The information needed to check them does exist. The catalogue is a file, its keys are known at build time, and TypeScript can express “one of these literals” as a union type. What is missing is only the wiring between the two, and it is a generator plus a signature change.
The generator
// packages/i18n-tooling/src/generate-keys.ts
import fs from 'node:fs';
import path from 'node:path';
const dir = 'packages/i18n-messages/src/locales/en';
const entries: string[] = [];
for (const file of fs.readdirSync(dir).sort()) {
const ns = path.basename(file, '.json');
const keys = Object.keys(JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')))
.filter((k) => !k.startsWith('_'))
.sort(); // stable order keeps the output deterministic
entries.push(...keys.map((k) => ` | '${ns}:${k}'`));
}
fs.writeFileSync(
'packages/i18n-messages/src/keys.d.ts',
`// GENERATED — run \`pnpm i18n:extract\`\nexport type MessageKey =\n${entries.join('\n')};\n`
);
Two details matter more than they look. Sorting keys makes the output deterministic, which is what lets the file be cached and diffed — the property discussed in cache misses on locale artifacts. And generating from the source catalogue rather than from a target is deliberate: targets legitimately lag behind, and typing against a lagging catalogue would reject keys that exist.
Typing the arguments too
Key typing alone catches misspellings. Typing the interpolation arguments catches the other half — a message that needs {count} being called without one.
// Extract argument names from the message at type level.
type Args<S extends string> =
S extends `${string}{${infer A}}${infer Rest}` ? A | Args<Rest> : never;
type VarsFor<K extends MessageKey> =
Args<(typeof messages)[K]> extends never
? [vars?: undefined]
: [vars: Record<Args<(typeof messages)[K]>, string | number>];
export function t<K extends MessageKey>(key: K, ...rest: VarsFor<K>): string;
This is more type machinery than most teams want, and it is worth being honest about the trade: it catches a real class of bug, and it makes error messages harder to read when something else is wrong. A reasonable middle position is to type keys always and arguments only in the packages where interpolation is dense.
Keeping the type from drifting
A generated type that is not regenerated is worse than no type, because it is confidently wrong: it rejects new keys and accepts removed ones.
The fix is to make generation part of extraction rather than a separate command someone remembers. Extraction already runs whenever source files change, and the catalogue it produces is precisely the generator’s input, so chaining them means the type is always current by construction.
CI then needs one assertion: regenerate, and fail if the result differs from what is committed. That check is what turns “the type should be current” into “the type is current”, and it fails with a diff that tells the author exactly what to commit.
pnpm i18n:generate-keys
git diff --exit-code packages/i18n-messages/src/keys.d.ts \
|| { echo 'key type is stale — commit the regenerated file'; exit 1; }
What typing does not solve
It is worth naming the boundary, because typed keys create a feeling of safety that extends further than the guarantee does.
Typing proves a key exists in the source catalogue. It says nothing about whether the key is translated in any target locale, which is the coverage question answered by the gates in GitHub Actions i18n CI gates. It says nothing about whether the string is the right one for the context, which is a review question. And it says nothing about plural categories, because which category a number selects is decided at runtime by CLDR data — the subject of pluralization rules across languages.
There is also a class of key it cannot cover at all: keys assembled at runtime. t(\status.${code}`)cannot be typed against a union unlesscode` itself is a union, and if it is, then writing the mapping explicitly is both typeable and clearer. That is the same argument the governance rules make for banning dynamic keys outright.
Verification
# A misspelled key must not compile
echo "t('chekout.submit')" >> apps/web/src/scratch.ts
npx tsc --noEmit
# error TS2345: Argument of type '"chekout.submit"' is not assignable
# to parameter of type 'MessageKey'.
# The committed type matches the catalogue
pnpm i18n:generate-keys && git diff --exit-code packages/i18n-messages/src/keys.d.ts
Rolling it out on an existing codebase
Switching a large workspace to typed keys in one commit produces hundreds of errors, most of them legitimate and none of them urgent. A staged rollout keeps the build green and turns the errors into a work queue rather than a blocker.
Start by generating the type and exporting it without changing any signature. Nothing breaks, and the type is available for anyone who wants it. This step alone is worth landing early, because it makes the next steps a configuration change rather than a code change.
Then widen the signature gradually. A translate function typed as MessageKey | (string & {}) accepts anything, still offers autocompletion for real keys, and produces no errors. It is a genuinely useful intermediate state: developers get the editor benefit immediately while nothing is enforced.
Narrow it package by package. Each package that removes the escape hatch gets one focused pull request whose diff is a list of key corrections — and those corrections are almost always real bugs, either typos that were silently falling back or references to keys that were removed at some point.
Finish with the staleness check in CI. Adding it earlier means failing builds for packages that have not migrated yet, which is exactly the friction that gets a rollout abandoned.
The errors uncovered along the way tend to cluster in two places: test files, where keys are written by hand more often than in application code, and older surfaces that were localized before the conventions settled. Neither is urgent, and both are more pleasant to fix as a bounded list than as a surprise in production.
When to escalate
If type-checking becomes slow, the union is probably very large — tens of thousands of literals will do it. Splitting the type per namespace, so a consumer imports only the namespaces it uses, restores performance and matches the namespace boundaries the catalogue already has.
If a consumer cannot see the type, check that the messages package exports its declarations and that the consumer’s TypeScript configuration resolves them. A package whose types field is missing silently falls back to any, which looks exactly like the type not working.
If keys pass type-checking and still miss at runtime, the catalogue that generated the type is not the catalogue being loaded — usually two versions of the messages package, which is the duplication covered in two i18n instances in a workspace.
FAQ
Should the generated file be committed?
Yes. Committing it means the type is available without running a build, keeps editor tooling working on a fresh checkout, and makes the staleness check possible — you cannot diff against a file that is not there.
Does this work with JavaScript consumers?
Partially. A JavaScript package with checked types via JSDoc gets the same errors; a package with no type checking at all gets none. In a mixed workspace the typed packages still benefit, which is usually enough of an argument.
What about keys used only in tests?
They type-check like any other key, which is correct — a test referencing a removed key should fail to compile rather than assert against a raw key string. If a test genuinely needs an invalid key to test the miss path, casting it explicitly documents that intent.
Is a runtime check still needed?
Yes, for anything crossing a boundary the compiler does not see: keys from a content system, from an API, or from configuration. Typing covers the code you compile, and a missing-key handler covers everything else — the safety net described in fallback chain configuration.
Related
- Monorepo i18n Package Architecture — where the generator and the type live.
- String Catalog Governance — the rule against runtime-assembled keys that makes typing possible.
- Cache misses on locale artifacts — why the generator must be deterministic.
- GitHub Actions i18n CI Gates — the coverage question typing does not answer.
Part of Monorepo i18n Package Architecture.