Skip to content

Colocated i18n library: design specΒ #8

Description

@jhnns

Colocated i18n Library β€” Design Spec

Context

The goal is a small, opinionated i18n convention and runtime container for this toolkit β€” not a full i18n engine. The hard i18n work (ICU formatting, pluralization, number/date formatting) stays deferred to whatever library the consuming app already uses; this package only provides:

  • A colocation convention: a.ts has a sibling a.i18n/ folder containing one JSON file per locale (a.en.json, a.de.json, a.de-AT.json, …).
  • A tiny runtime container that loads the right locale file for the active locale, with BCP-47 best-fit fallback, and returns a typed accessor function.
  • No global translation-key registry β€” every module's keys are local to that module, derived from its own JSON files.
  • First-class TypeScript support: translation keys are checked at compile time, and (best-effort) whether a key requires ICU placeholder arguments.

This was arrived at through an extended design discussion that ruled out several simpler-looking approaches for concrete technical reasons (see "Rejected approaches" below) β€” worth reading before proposing alternatives, since the reasons are non-obvious.

Recommended architecture

Runtime API

const i18n = await getI18n();               // auto-detects caller via CallSite trick (Node/Deno/Bun)
const i18n = await getI18n(import.meta);     // explicit fallback, spec-guaranteed, always available

i18n("welcome");                              // TS-checked key
i18n("greeting", { name: "Jo" });             // TS requires arg if the key has an ICU placeholder

Scope for this increment: native ESM runtimes only (Node, Deno, Bun), unbundled. Browser/bundler support (Vite plugin, generic file watcher for other bundlers) is an explicitly separate, later increment β€” the generated-file shape this produces is what a future bundler-side generator would also need to produce, so this isn't throwaway work.

Caller-location detection

  1. Primary: Error.prepareStackTrace / CallSite API. Override Error.prepareStackTrace temporarily, capture a new Error() inside getI18n, walk to the caller's frame, and read callSite.getFileName(). Prefer this over regex-parsing .stack strings β€” the CallSite API is structured and is what callsites/error-stack-parser/Jest/Vitest use internally for the same purpose.
  2. Fallback: explicit import.meta argument. Always available, spec-guaranteed, no engine-specific behavior. Use this when the automatic trick can't be trusted.
  3. Per-engine caveats to test directly, not assume:
    • Node: native V8, most reliable target.
    • Deno: implements Error.prepareStackTrace (compatibility fixes landed as late as Deno 1.46 β€” pin a minimum version). Filesystem writes require explicit --allow-read/--allow-write permissions β€” document this as a real user-facing requirement, not just a compatibility detail.
    • Bun: runs JavaScriptCore, not V8, but deliberately emulates the V8 stack-trace API for Node-compat. Has had real bugs (negative line/column numbers breaking source-map-support, prepareStackTrace undefined by default) β€” pin a minimum Bun version and test directly.
    • TS execution loaders (tsx, ts-node, vite-node, Node's native type-stripping): verify the CallSite's file name resolves to the real .ts source path (via source maps) rather than a transformed/in-memory location, for each loader you want to support.
  4. Fail loudly, not silently, if automatic detection can't be trusted in the current environment β€” throw an actionable error directing the caller to pass import.meta explicitly, rather than generating from a wrong path.
  5. Never run the filesystem-writing half in production. Gate strictly behind a dev-only check (e.g. NODE_ENV !== "production"). Production filesystems are often read-only (serverless cold starts, some containers) β€” attempting a write there is a reliability bug, not just unnecessary work. Production should always consume already-generated files.

Shared codegen core

One plain function, e.g. generate(sourceDir: string, basename: string): void, that:

  • Reads <basename>.i18n/*.json.
  • Designates one locale as canonical/source-of-truth (e.g. en) β€” other locales are trusted at runtime, not statically checked against it.
  • Computes the exact key set and (best-effort) which keys require ICU placeholder arguments, by parsing real JSON content β€” not via type-level template-literal-type tricks, which can't express real ICU (plural/select/nesting) anyway.
  • Emits a companion module (e.g. <basename>.i18n.gen.ts) containing the typed accessor and the runtime BCP-47 fallback-chain wiring, built from the actual list of locale files that exist on disk (so runtime selection is a direct lookup, not a trial-and-error import-and-catch cascade).

All three trigger mechanisms (the CallSite-driven runtime path now, plus a future filesystem watcher and Vite plugin) should call into this one core β€” do not let generation logic diverge across triggers.

BCP-47 best-fit fallback

Given a requested locale (de-AT) and the known set of locale files for a module, walk: exact tag β†’ strip region β†’ strip script β†’ configured default. E.g. de-AT β†’ de-AT (miss) β†’ de (hit). This is the same lookup algorithm CLDR/Intl.LocaleMatcher use.

Zero-dependency constraint

Do not bundle an ICU formatter. Accept one via injection (the app's existing i18n library formats the actual string; this package only resolves which file/locale to load and checks which keys exist).

Rejected / deferred approaches (why, briefly)

  • getI18n(import.meta) doing bundler-time file discovery internally β€” bundlers (Rollup/webpack) need a literal import specifier or a literal import.meta.glob/context call at the consumer's own call site to statically analyze; wrapping either inside a library function breaks static analysis because the literal call site ends up inside the library's file, not the caller's.
  • Hand-authored per-locale loader maps β€” explicitly rejected by the user; conflicts with "runtime should select the best-fit chunk," not just load whatever the caller enumerated.
  • Full ICU type-checking via TypeScript template-literal types β€” can express simple {placeholder} holes but not real ICU (plural/select/nesting); the codegen approach handles this better since it parses real JSON with real code instead of type-level string parsing.
  • Vite virtual modules / bundler-specific plugins as the only mechanism β€” bundler-specific, doesn't help native Node/Deno/Bun execution at all. Deferred to a later increment, layered on top of the same generated-file shape.
  • A vue-tsc-style custom tsc CompilerHost wrapper (to avoid any file ever touching disk, including for type-checking) β€” real, proven pattern, but the heaviest option; only worth it if writing real (gitignored) generated files to disk turns out to be an actual dealbreaker in practice.

Verification

Since this is a from-scratch design increment with no existing code to run yet, verification for this phase is about validating the two riskiest technical assumptions before committing further design/implementation time:

  1. Spike: Error.prepareStackTrace/CallSite behavior across Node (current LTS), Deno (latest), and Bun (latest) β€” confirm caller file paths resolve correctly, including when run through at least one TS loader (e.g. tsx).
  2. Spike: BCP-47 fallback chain β€” small standalone test of the lookup algorithm against a handful of locale-tag scenarios (exact match, region-fallback, script-fallback, default-fallback).

Once implemented as an actual package, it should follow this repo's existing utility conventions (src/<name>/ folder, co-located .test.ts, own README.md, exports registered in package.json/jsr.json/.size-limit.json β€” see src/sleep/ and src/concurrency/once/ as reference examples found during this session's exploration).

Next step

Write up this spec as a GitHub issue (title + body drawn from this document) once the plan is approved.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions