Skip to content

feat(translate): typed keys for TranslateService and TranslatePipe#1620

Open
GuyHaviv37 wants to merge 2 commits into
ngx-translate:developfrom
GuyHaviv37:guyhaviv37/typed-translation-keys
Open

feat(translate): typed keys for TranslateService and TranslatePipe#1620
GuyHaviv37 wants to merge 2 commits into
ngx-translate:developfrom
GuyHaviv37:guyhaviv37/typed-translation-keys

Conversation

@GuyHaviv37

Copy link
Copy Markdown

Summary

  • Add an optional Key generic to TranslateService, ITranslateService, and TranslatePipe (defaults to string for full backward compatibility)
  • Type key parameters on get, stream, instant, getStreamOnTranslationChange, getParsedResult, set, translate, and TranslatePipe.transform as Key | Key[] instead of string | string[]
  • Consumers can supply a union of valid translation keys and get compile-time validation plus IDE autocomplete when resolving translations
  • Add translate.type-safety.spec.ts with @ts-expect-error assertions to lock in the type contract

Motivation

Translation keys are currently plain strings, so typos and invalid nested paths (e.g. 'b' instead of 'b.a') are only caught at runtime. By parameterizing the service and pipe with a key type, apps can define their known keys once and have TypeScript enforce them across the main lookup APIs.

Usage

type AppTranslationKeys = "a" | "b.a" | "b.b" | "c";

// Service
const translate = inject<TranslateService<AppTranslationKeys>>(TranslateService<AppTranslationKeys>);

translate.instant("a");      // OK
translate.stream("b.a");     // OK
translate.get("b.b");        // OK

translate.get("c.c");        // TS error — invalid key
translate.get("b");          // TS error — parent object, not a leaf key

// Pipe
const pipe = inject<TranslatePipe<AppTranslationKeys>>(TranslatePipe<AppTranslationKeys>);
pipe.transform("b.a");       // OK
pipe.transform("invalid");   // TS error

Existing code that does not pass a generic continues to work unchanged — TranslateService and TranslatePipe default to Key = string.

Author's note

I tried keeping the PR as simple as possible and not to over-complicate things while having some validation and assurance with the simple test case.
If needed and per guidance of the maintainers, we can add more layers of testing and/or type-safety overhaul.

Thanks!

@GuyHaviv37 GuyHaviv37 changed the title feat(translate): typed keys for TranslateService and TranslatePipecuro feat(translate): typed keys for TranslateService and TranslatePipe Jun 5, 2026
@CodeAndWeb

Copy link
Copy Markdown
Member

Thanks for this, @GuyHaviv37 — really appreciate the work, and the restraint in keeping it focused. Typed keys are something people have asked for, and your PR is clean, fully backward-compatible (the Key = string default is exactly right), and the @ts-expect-error spec is a nice way to lock the contract. I verified it compiles and the type checks genuinely fire, so the mechanism is solid.

Before we merge I want to talk through the shape, because while digging into it I hit a wall that I think changes the direction.

The per-instance generic can't reach templates. Angular template syntax has no way to pass a type argument to a pipe or directive, so {{ 'key' | translate }} always resolves to TranslatePipe regardless of the generic, and the [translate] directive stays untyped. Since most keys live in templates, the generic ends up protecting the surface that needed it least. On the imperative side it works, but only if you write inject<TranslateService>(...) at every injection site.

There's one mechanism that does reach templates: a globally-augmentable key type (the i18next CustomTypeOptions pattern). Instead of a per-instance generic, the library exports an empty interface that the consumer augments once via declaration merging. Because the resulting key type is ambient, Angular's strict-template checker enforces it on the pipe and directive too. And we can have the service generic default to it, so plain inject(TranslateService) gets typed keys on every method with no per-callsite ceremony:

// library
export interface NgxTranslateConfig {}  // empty -> falls back to string
type AppKey = NgxTranslateConfig extends { keys: infer K extends string } ? K : string;

class TranslateService<Key extends string = AppKey> {
  instant(key: Key | Key[]): ...
}

// consumer, once
declare module "@ngx-translate/core" {
  interface NgxTranslateConfig { keys: AppKey }
}

That covers get / instant / stream / translate / the pipe / the directive — all at once, automatically.

And the keys can come from the JSON rather than a hand-maintained union. A small recursive type derives the dotted-leaf paths from a statically-imported translation file:

type DeepKeys<T> = T extends string ? never
  : { [K in keyof T & string]: T[K] extends string ? K : `${K}.${DeepKeys<T[K]>}` }[keyof T & string];

type AppKey = DeepKeys<typeof en>;  // "a" | "b.a" | "b.b" | "c"

I prototyped both pieces and confirmed they compile and enforce correctly. The combination is a strict superset of what this PR does, and a power user can still pass an explicit generic to override the key-space for a specific subtree.

The tradeoffs to weigh: the registry is global (one key-space per app, which is fine for the vast majority), and DeepKeys over a very large dictionary can stress the TypeScript compiler — both known from the i18next ecosystem, so we'd want a stress test before committing.

I've added this to our backlog as the direction to pursue. Would you be interested in taking a run at the registry + DeepKeys version? Happy to sketch the API surface and the strict-template test with you. Either way, thank you — this PR is what surfaced the right design.

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