Typed messages
How translation file imports are typed, and how to push typing further
SayKit emits a declaration file alongside every translation file so that importing it from your source compiles without complaint:
src/locales/
en.po
en.d.po.ts
fr.po
fr.d.po.tsimport en from './locales/en.po';
// en is typed as: Record<string, string>The auto-generated declaration is intentionally simple:
declare const translations: Record<string, string>;
export default translations;That's enough to make the import type-check, even before your build plugin has run, and it's the only declaration shape your tooling needs to handle.
The .d.{extension}.ts name
The name isn't arbitrary. To type an import of ./locales/en.po, TypeScript strips the extension it's given and looks for en.d.po.ts — the sibling-declaration convention for non-TypeScript files. A file named en.po.d.ts is not consulted for a specifier TypeScript recognises, so it would silently do nothing.
For any extension other than .json, this also requires a compiler flag:
{
"compilerOptions": {
"allowArbitraryExtensions": true
}
}Without it, import en from './locales/en.po' fails to resolve no matter what the declaration is called. JSON catalogues need resolveJsonModule instead, which moduleResolution: "bundler" already implies.
Why generic?
You'll notice the declaration is Record<string, string> rather than something narrow like { "abc123": string; "def456": string }. That's deliberate:
- Identifiers are content hashes by default. They're stable, but not meant to be consumed by hand.
- The macros (
say`...`,<Say>) are what your application code writes. The transform inserts the right id at build time, so your code never needs to know about hashes. - Narrowing the type to specific keys would force the type to stay in lock-step with extraction, saving and re-running would constantly create diffs.
In short: you never reference an id from application code. The build does that for you.
Committing translation files
SayKit doesn't manage a .gitignore for you — it's up to you which generated files to commit or ignore. A common setup:
- Commit the
.pofiles. They're the canonical translations. - Either commit or ignore the
.d.po.tsfiles. SayKit regenerates them on every extraction, so ignoring them keeps diffs quiet; committing them lets CI type-check without an extra extraction step.
If you want to ignore the declarations, add a line like *.d.po.ts to your project's .gitignore.
Importing in different bundlers
The actual JS module is produced by the SayKit build-tool plugin (or the formatter, at extraction time, indirectly). All of these work:
// Bundled at build time, synchronous, in the initial chunk
import en from './locales/en.po';// Code-split, dynamic import per locale
const en = await import('./locales/en.po').then((m) => m.default);// Per-locale loader function (recommended for many locales)
new Say({
loader: (l) => import(`./locales/${l}.po`).then((m) => m.default),
});See dynamic loading for the lazy patterns.
Custom-id messages
When you assign a custom id via a descriptor, that id ends up as the key in the catalogue:
say({ id: 'greeting.hello' })`Hello!`;greeting.hello → "Hello!"The runtime say.call({ id: 'greeting.hello' }) works exactly the same way as for hashed ids; nothing in your app code touches it directly. The id only matters to translators, who'll see it in the catalogue.
Next
- Extraction: how files are generated
- Custom formatter: if you want different generated declarations