Skip to content

feat: typed translation keys via augmentable registry and DeepKeys#1634

Open
CodeAndWeb wants to merge 1 commit into
developfrom
feat/typed-translation-keys-registry
Open

feat: typed translation keys via augmentable registry and DeepKeys#1634
CodeAndWeb wants to merge 1 commit into
developfrom
feat/typed-translation-keys-registry

Conversation

@CodeAndWeb

Copy link
Copy Markdown
Member

Typed translation keys — augmentable registry + DeepKeys

Opt-in compile-time validation and autocomplete for translation keys across the whole public surface — both the imperative API and the template APIs. Fully backward-compatible: with no augmentation, every key type stays string and existing apps compile unchanged.

Supersedes the per-instance-generic approach in #1620 (keeps that as an escape hatch) by making the registry the default key source, so plain inject(TranslateService), {{ key | translate }}, [translate], and *translateBlock are all keyed without per-call-site ceremony.

How you opt in

import en from "./assets/i18n/en.json";

declare module "@ngx-translate/core" {
  interface NgxTranslateConfig {
    keys: DeepKeys<typeof en>;
  }
}

That one augmentation retypes the entire library to the app's key-space — code and templates.

What's added

  • NgxTranslateConfig — augmentable registry interface (i18next CustomTypeOptions pattern). Ships empty, so TranslationKey falls back to string.
  • TranslationKey — resolves to the augmented keys union, or string when unaugmented.
  • DeepKeys<T> — derives the dotted leaf-path union from a translation shape ({ a: "A", b: { c: "C" } }"a" | "b.c"). Arrays and primitives are leaf keys; only plain objects recurse.
  • injectTranslateService<Key>() — keyed inject helper (a bare inject(TranslateService) of a generic class widens the key to any).
  • Generic threading on TranslateService<Key>, TranslatePipe<Key>, the [translate] directive, the *translateBlock t() helper, and the standalone translate() function.

Key-space coverage

Surface Registry default (code + template) Per-instance <Key> override
TranslateService inject<TranslateService<K>>(…)
translate() fn translate<K>(…)
TranslatePipe ✅ (imperative only; templates can't pass type args)
[translate] directive — (not generic; templates can't pass type args)
*translateBlock t() — (not generic)

Known limitation

The design assumes a single global key-space. Per-subtree / per-child-service typed key-spaces are not supported: templates can't pass type arguments, and the parent/root chain carries Key cosmetically (TS method bivariance), so a mixed-key-space hierarchy isn't soundly typed. Per-instance <Key> overrides remain a code-only escape hatch.

Tests

  • Type-level (type-tests/, pnpm run test-types): registry augmentation retypes the default service/pipe/directive/block/translate(); positive + @ts-expect-error negative coverage; a big-dictionary stress fixture (10-level deep, 100-leaf wide) guarding the tsc recursion / union-size ceiling.
  • End-to-end template (template-tests/, pnpm run test-templates): real ngtsc strictTemplates compiles proving in-union keys compile and out-of-union keys are rejected on all three template surfaces (| translate, [translate], *translateBlock).
  • Runtime (translate.type-safety.spec.ts): per-instance generic on service + pipe, backward-compat default-string path, DeepKeys leaf derivation incl. arrays.
  • CI now runs test-types and test-templates in the build job.

Compatibility

No runtime behavior change — all changes are type-level (erased at compile time). ng test: 432 (ngx-translate) + 28 (http-loader) pass; build-all, lint, test-types, test-templates all green.

Augment NgxTranslateConfig once to switch the whole library to a typed
key-space across both imperative and template APIs:

- NgxTranslateConfig registry + TranslationKey (i18next CustomTypeOptions
  pattern); defaults to string so existing apps are unaffected.
- DeepKeys<T> derives the dotted leaf-path union from a translation shape;
  arrays and primitives are leaf keys, only plain objects recurse.
- TranslateService<Key>, TranslatePipe<Key>, the [translate] directive,
  the *translateBlock t() helper, and the standalone translate() function
  are all keyed to the registry default; per-instance <Key> override is
  retained where it is reachable.
- injectTranslateService() helper for the imperative path, since a bare
  inject(TranslateService) widens the key to any.

Tests: type-level type-tests (tsc) plus end-to-end template type-tests
(ngtsc strictTemplates) covering the pipe, directive and *translateBlock,
plus a big-dictionary stress check. CI now runs test-types and
test-templates.
@CodeAndWeb

CodeAndWeb commented Jun 16, 2026

Copy link
Copy Markdown
Member Author

While this works for a scenario with only one main set of translations, it practically breaks with the modularity we added with ngx-translate 18.

One could provide a union over all JSON files - this would enable auto-complete but the checks per module are not there.

I am currently not really sure if this is a solution that should be supported or not...

It adds certain complexity - and is only a 30% solution and feels too incomplete to really add it to the library.

@GuyHaviv37

Copy link
Copy Markdown

@CodeAndWeb hey, seems like this PR is a bit less surgical than the one I offered, but I guess it's all the same.
Can you please share a bit more about your worries with adding this kind of functionality? what are the use-cases where you feel it's incomplete?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants