SayKit
Guides

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.ts
import en from './locales/en.po';
// en is typed as: Record<string, string>

The auto-generated declaration is intentionally simple:

src/locales/en.d.po.ts
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:

tsconfig.json
{
  "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 .po files. They're the canonical translations.
  • Either commit or ignore the .d.po.ts files. 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

On this page