Skip to content

feat(types): add ResourceNamespaceMap and enhance CustomTypeOptions#2434

Merged
adrai merged 4 commits into
i18next:masterfrom
sh3xu:feat/types-resource-namespace-map-2409
May 26, 2026
Merged

feat(types): add ResourceNamespaceMap and enhance CustomTypeOptions#2434
adrai merged 4 commits into
i18next:masterfrom
sh3xu:feat/types-resource-namespace-map-2409

Conversation

@sh3xu

@sh3xu sh3xu commented May 25, 2026

Copy link
Copy Markdown
Contributor

Add ResourceNamespaceMap to let monorepo packages declare their own i18n namespaces without TS2717. It merges with legacy CustomTypeOptions.resources to keep existing augmentations working (closes #2409).

  • New Features
    • Introduced and exported ResourceNamespaceMap for per‑package namespace types in separate i18next.d.ts files.
    • TypeOptions['resources'] now builds from legacy resources and ResourceNamespaceMap, allowing both patterns to coexist.
    • Added TypeScript tests for monorepo setups, overlapping namespaces, default NS behavior, and backward compatibility.

…or monorepo support

- Introduced ResourceNamespaceMap interface for per package namespace types.
- Updated CustomTypeOptions to work alongside ResourceNamespaceMap, allowing for better type management in monorepos.
- Implemented tests to validate the new types and ensure compatibility with existing functionality.
@coveralls

coveralls commented May 25, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 94.977%. remained the same — sh3xu:feat/types-resource-namespace-map-2409 into i18next:master

@adrai adrai left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking this up — checked it out locally and walked the type plumbing end-to-end. Full type-test suite passes (480/480, zero type errors), and the new tests cover the multi-package monorepo scenario exactly as discussed in #2409. The two-step $MergeBy is the right shape; without the second overlay, registry contributions would get dropped whenever CustomTypeOptions.resources is also set.

A few suggestions before merging:

1. Same-key conflict becomes silently never

When both legacy and registry contribute the same namespace and declare the same key with different literals, the & intersection collapses that key to never — no TS error, no warning. The test turns clashing literals on the same key into never only exercises TS's built-in intersection behavior on synthetic types; it doesn't actually go through _MergedResources. Could you add an end-to-end test that triggers this through the real path? Something like adding shared_conflict: 'A' to package-app-legacy.d.ts and shared_conflict: 'B' to package-overlap.d.ts, then asserting t('shared_conflict') returns never on TFunction<'overlap'>.

Not blocking — the behavior itself is the unavoidable trade-off of allowing both surfaces — but if we ship it, we should verify the integration path actually does what we think it does.

2. Expand the HACK: comment

The current comment is honest but a future reader has to reverse-engineer the reasoning. Suggestion:

// Apply merged resources *after* CustomTypeOptions so registry contributions
// survive when CustomTypeOptions.resources is also defined. Without this
// second step, the inner $MergeBy would replace the default `resources: object`
// with CustomTypeOptions['resources'] alone, dropping the registry namespaces.

3. Clarify the JSDoc on ResourceNamespaceMap

Worth one extra line spelling out the separation of concerns, so users don't try to put scalar options here:

/**
 * ...
 * Scalar type options like `defaultNS`, `returnNull`, `enableSelector`,
 * etc., still belong on `CustomTypeOptions` — this interface is for
 * namespace resource types only.
 * ...
 */

4. Optional: a selector-mode test for the registry path

All existing selector tests use the legacy CustomTypeOptions.resources. Adding a single selector test (e.g. with enableSelector: 'optimize') using a registry-contributed namespace would prove the selector codegen path also reads through _MergedResources correctly. The internal alias chain says it does — but a test would make the guarantee explicit. Not a blocker; happy to do it as a follow-up.


Aside from those, the change is contained, backwards-compatible, and well-scoped. Verified that no companion changes are needed in react-i18next or next-i18next — neither references TypeOptions['resources'] directly. CHANGELOG can come with the release.

Thanks again for the clean, well-tested PR!

@sh3xu

sh3xu commented May 25, 2026

Copy link
Copy Markdown
Contributor Author

@adrai I tried the exact setup (shared_conflict: 'A' in legacy and 'B' in registry, then t('shared_conflict') on TFunction<'overlap'>).
What I found is _MergedResources itself seems to be doing the right thing ('A' & 'B' -> never). The issue starts a bit later during key building. KeysBuilderWithoutReturnObjects sees never, goes down the object path, ends up in keyof never, and TS kind of spirals from there with the "Type instantiation is excessively deep" error.
So it doesn't really fail only on that conflicting key one conflicting overlap entry ends up blowing up the broader t() inference.

@adrai

adrai commented May 25, 2026

Copy link
Copy Markdown
Member

@sh3xu great catch — I reproduced your finding locally, dug into why it happens, and have a verified fix below. You weren't imagining it: the conflict scenario does break the whole t() signature, not just the conflicting key.

Root cause — two TS quirks compound

Quirk 1: L & R collapses to never when any property's literals conflict.

type A = { x: 'A'; y: 'common' };
type B = { x: 'B'; z: 'beta' };
type AB = A & B;
//   ^? never (NOT { x: never; y: 'common'; z: 'beta' })

This means a single same-key/different-literal disagreement (shared_conflict: 'A' vs 'B') annihilates the entire overlap namespace — legacy_only, registry_only, shared_literal all vanish.

Quirk 2: KeysBuilder recurses into never, and keyof never is string | number | symbol.

Once a resource value becomes never, the key builder in t.d.ts tries to walk it. keyof never is string | number | symbol (not never), so the recursion explodes into "Type instantiation is excessively deep". That poisons t() overload resolution for every namespace in the program — exactly what you saw with @repo/ui falling back to the TemplateStringsArray overload.

Fix (two parts, both required)

1. typescript/options.d.ts — per-property merge with conflict filtering

Replace the naive _LegacyResources & ResourceNamespaceMap with a structural per-property merge, then drop never-valued keys with Pick:

type _LegacyResources = CustomTypeOptions extends { resources: infer R } ? R : object;

// Per-property merge of two object types. Unlike `L & R`, which TypeScript
// collapses to `never` whenever ANY property has incompatible literals (so a
// single same-key/different-literal conflict wipes out the whole namespace),
// this iterates keys and intersects them individually — the conflicting key
// becomes `never`, the rest survive.
type _PerPropMerge<L, R> = {
  [K in keyof L | keyof R]: K extends keyof L
    ? K extends keyof R
      ? L[K] & R[K]
      : L[K]
    : K extends keyof R
      ? R[K]
      : never;
};

// Drop properties whose value resolved to `never`. Without this, the
// conflict key would leak into `keyof Resources[ns]`, then poison
// `KeysBuilder` recursion (it tries to walk a `never` value) and break
// `t()` overload resolution for the entire namespace.
// NOTE: must use the `Pick<T, NonNeverKeys>` form. A `[K in keyof T as ...]`
// remap does NOT eagerly evaluate `T[K]` for intersection types, so conflict
// keys would survive the filter.
type _NonNeverKeys<T> = {
  [K in keyof T]: [T[K]] extends [never] ? never : K;
}[keyof T];
type _DropConflictKeys<T> = Pick<T, _NonNeverKeys<T>>;

// When the same namespace exists on both sides, deep-merge per property and
// strip same-key/different-literal conflicts. Otherwise pick from whichever
// side has it.
type _MergeNamespaces<L, R> = {
  [K in keyof L | keyof R]: K extends keyof L
    ? K extends keyof R
      ? _DropConflictKeys<_PerPropMerge<L[K], R[K]>>
      : L[K]
    : K extends keyof R
      ? R[K]
      : never;
};

type _MergedResources = [keyof _LegacyResources] extends [never]
  ? [keyof ResourceNamespaceMap] extends [never]
    ? object
    : ResourceNamespaceMap
  : [keyof ResourceNamespaceMap] extends [never]
    ? _LegacyResources
    : _MergeNamespaces<_LegacyResources, ResourceNamespaceMap>;

2. typescript/t.d.ts — defensive [Res] extends [never] guards on the key builders

Defense-in-depth so any case (including deeply nested conflicts) that reaches the key builder doesn't spiral:

type KeysBuilderWithReturnObjects<Res, Key = keyof Res> = [Res] extends [never]
  ? never
  : Key extends keyof Res
    ? Res[Key] extends $Dictionary | readonly unknown[]
      ?
          | JoinKeys<Key, WithOrWithoutPlural<keyof $OmitArrayKeys<Res[Key]>>>
          | JoinKeys<Key, KeysBuilderWithReturnObjects<Res[Key]>>
      : never
    : never;

type KeysBuilderWithoutReturnObjects<Res, Key = keyof $OmitArrayKeys<Res>> = [Res] extends [never]
  ? never
  : Key extends keyof Res
    ? Res[Key] extends $Dictionary | readonly unknown[]
      ? JoinKeys<Key, KeysBuilderWithoutReturnObjects<Res[Key]>>
      : Key
    : never;

3. Update the tests to exercise the conflict integration path

In test/typescript/registry-monorepo/package-app-legacy.d.ts, add to overlap:

shared_conflict: 'A';

In test/typescript/registry-monorepo/package-overlap.d.ts, add to overlap:

shared_conflict: 'B';

In test/typescript/registry-monorepo/t.test.ts, replace the existing synthetic conflict test with a real integration assertion:

it('drops same-key/different-literal conflicts from the merged namespace', () => {
  // `shared_conflict` is 'A' in legacy and 'B' in registry. Without
  // filtering, the entire `overlap` namespace would collapse to `never`
  // (TS reduces `{ x:'A' } & { x:'B' }` to `never`) and poison `t()`
  // overload resolution.
  // @ts-expect-error 'shared_conflict' is dropped because legacy says 'A', registry says 'B'
  assertType(t('shared_conflict'));
});

Verified locally

  • All 14 registry-monorepo tests pass with the conflict in place.
  • Full TS suite: 480 tests pass, zero type errors, no regressions.

TS quirks worth a comment near the code

Both were non-obvious enough that I'd recommend leaving the inline comments above as-is — they document why each piece looks the way it does:

  • [T[K]] extends [never] only triggers when TS forces evaluation of the intersection. In a key remap ([K in keyof T as ...]), the check is deferred and doesn't fire. The Pick<T, NonNeverKeys> form forces it.
  • keyof never is string | number | symbol, which is why the recursion guard is essential.

Happy to push these changes if you'd prefer — maintainerCanModify is on. Otherwise feel free to apply directly.

sh3xu and others added 3 commits May 26, 2026 11:17
- Added shared_conflict properties to legacy and registry namespaces to manage conflicting literals.
- Introduced package-selector.d.ts to define enableSelector in the i18next module.
- Enhanced type merging logic to drop conflicting keys while preserving valid ones.
- Added shared_conflict properties to legacy and registry namespaces to manage conflicting literals.
- Introduced package-selector.d.ts to define enableSelector in the i18next module.
- Enhanced type merging logic to drop conflicting keys while preserving valid ones.
@adrai

adrai commented May 26, 2026

Copy link
Copy Markdown
Member

Just noticed you pushed updates — thanks for the quick turnaround. Verified locally:

  • Fix applied verbatim in options.d.ts and t.d.ts
  • Expanded HACK: comment + clarified ResourceNamespaceMap JSDoc ✓
  • Conflict integration test in place (shared_conflict: 'A' vs 'B') ✓
  • Bonus: selector-mode coverage via the separate tsconfig.selector.json compile unit. Choosing enableSelector: true over 'optimize' actually exercises the type machinery more thoroughly than I'd suggested — nice call.

Full TS suite: 496 tests pass, zero type errors, no regressions.

Looks good from my side. Are you happy with where it is, or is there anything else you'd like to try before we merge?

@sh3xu

sh3xu commented May 26, 2026

Copy link
Copy Markdown
Contributor Author

@adrai Happy with it as is from my side thanks again for validating everything locally.

Nothing else I'd push for before merge. If theres any edge case or additional coverage you think would be valuable here though, I’d be interested to hear your take always useful stuff to learn from.

@adrai adrai merged commit 159506c into i18next:master May 26, 2026
9 checks passed
adrai added a commit that referenced this pull request May 26, 2026
@adrai

adrai commented May 26, 2026

Copy link
Copy Markdown
Member

thx a lot... it's included in v26.3.0

@sh3xu sh3xu deleted the feat/types-resource-namespace-map-2409 branch May 27, 2026 12:17
adrai pushed a commit that referenced this pull request Jun 3, 2026
… keys (#2436)

The `[Res] extends [never]` guards added to KeysBuilderWith(out)ReturnObjects
in #2434 defer those builders into conditional types, so `KeyPrefix<Ns>` stops
resolving to a literal union. A `KPrefix extends KeyPrefix<Ns>` generic (e.g.
react-i18next's useTranslation) then infers the whole constraint instead of the
passed literal, polluting t() return types with unrelated sibling keys' values
(and TS2589 on deep trees). Restore the eager 26.2.0 builders; the guards were
inert (top-level conflicts are handled by _DropConflictKeys before the builders
run, and nested conflicts behave identically with or without them).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

TypeScript: CustomTypeOptions.resources doesn't compose across monorepo packages

3 participants