Skip to content

fix(types): derive defaultNS from generated resources when extract.defaultNS is false - #280

Merged
adrai merged 1 commit into
i18next:mainfrom
tarikermis:fix/types-defaultns-false
Aug 9, 2026
Merged

fix(types): derive defaultNS from generated resources when extract.defaultNS is false#280
adrai merged 1 commit into
i18next:mainfrom
tarikermis:fix/types-defaultns-false

Conversation

@tarikermis

Copy link
Copy Markdown
Contributor

What & why

When a project sets extract.defaultNS: false (the namespace-less layout: one JSON file per language), i18next-cli types emits defaultNS: false into the generated CustomTypeOptions. i18next's type system cannot express defaultNS: falseDefaultNamespace = TypeOptions['defaultNS'] feeds Ns extends Namespace (string | readonly string[]) — so every t() call silently accepts any string and the generated types provide zero key checking.

This PR derives the emitted defaultNS from the generated Resources keys instead (deduplicated, sorted, preferring the conventional 'translation'), prints a warning naming the derived namespace, and keeps false (with a warning) only when the resource files are keyed per language (e.g. en.json + de.json), where deriving would turn locales into namespaces and produce false type errors. The derivation only runs when i18next.d.ts is first created; resources.d.ts content and --ci behavior are unchanged.

Reproduction / verification

Found by running the CLI against a real project (defaultNS: false, one file per language, ~220 keys):

  1. i18next-cli types generated defaultNS: false + Resources { "en": {...} }.
  2. Compiling t('this.key.does.not.exist') with those definitions (moduleResolution: bundler, i18next@26.3.6, TS 5.9.3 and 6.0.3) passed — bogus keys accepted everywhere.
  3. With this fix, the same project emits defaultNS: 'en'; the bogus key now fails with TS2345 while valid keys type-check. Multi-namespace shapes (defaultNS: ['hello','stacks'] vs a single entry) were also compile-verified: unprefixed keys resolve against the first namespace only, prefixed sibling keys (t('stacks:title')) type-check, bogus keys are rejected.

Checks run

  • npm test (eslint + vitest): 114 files / 1250 tests pass, including 5 new/updated tests in test/types-generator-ts.test.ts (single-file derivation, namespaced layout, per-language fallback, dedupe/sort, warn-only-on-create, empty resources).
  • Full end-to-end: built CLI, regenerated types for the real project, compiled them with tsc --strict as above.

Review

The diff was reviewed iteratively (4 rounds) by an automated reviewer (kiro-cli, claude-opus-5); its findings led to the dedupe/sort, the per-language guard, the warnings, and the extra tests. Final round reported no blocking issues.

Notes for reviewers

  • The pre-existing test asserted defaultNS: false; was emitted — that behavior was added in fbd7608 to fix the emission syntactically; the semantic gap (untyped t()) was not addressed there.
  • When several namespaces exist and none is 'translation', the pick is alphabetically first — deterministic and called out by the warning, since the correct runtime default is not knowable statically.
  • Pre-existing quirk (untouched): with a custom types.input spanning multiple languages but without mergeNamespaces, Resources is keyed per language file; this PR intentionally does not change how resources.d.ts is built.

@adrai
adrai merged commit e42bdaf into i18next:main Aug 9, 2026
8 checks passed
@adrai

adrai commented Aug 9, 2026

Copy link
Copy Markdown
Member

Merged, thanks — the analysis is right, and the reproduction made it easy to verify. options.d.ts:591 does allow defaultNS: false, but every t() overload in t.d.ts defaults Ns extends Namespace = DefaultNamespace, so false degrades key checking to string. Emitting a namespace that's actually a key of the generated Resources interface is the self-consistent fix, and scoping resources.d.ts out of it was the right call.

I pushed two follow-ups on top in 4540650:

  1. The locale-layout guard used every, so one file outside config.locales defeated it — locales: ['en'] with types.input: 'locales/*.json' matching en.json + de.json derived defaultNS: 'de', a locale posing as a namespace. Switched to some: a single locale-named sibling is enough to recognize the layout, and falling back to false is only ever the pre-existing behavior.
  2. filter(ns => !ns.startsWith('..')) excluded out-of-basePath namespaces from the derivation while they still landed in Resources (they're pushed unconditionally), so files entirely outside basePath gave defaultNS: false next to a populated interface. They're now ranked last rather than dropped — a valid sibling still wins the pick, but they stay usable as a last resort since they are real keys of the emitted interface.

Two tests cover the new branches. Released in v1.67.9.

@tarikermis

Copy link
Copy Markdown
Contributor Author

Thanks for the quick merge, appreciate it! Found this one by running the CLI against one of my own projects, so glad the reproduction made it easy to verify. Best Regards, Tarik

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