Skip to content

fix(types): keyPrefix no longer pollutes t() return type with sibling keys (#2434 regression)#2436

Merged
adrai merged 1 commit into
i18next:masterfrom
aaronrosenthal:fix/keyprefix-return-type-regression-2434
Jun 3, 2026
Merged

fix(types): keyPrefix no longer pollutes t() return type with sibling keys (#2434 regression)#2436
adrai merged 1 commit into
i18next:masterfrom
aaronrosenthal:fix/keyprefix-return-type-regression-2434

Conversation

@aaronrosenthal

Copy link
Copy Markdown
Contributor

fix(types): keyPrefix no longer pollutes t() return type with sibling keys (#2434 regression)

Summary

26.3.0 (#2434) regressed t() return types for any project that scopes a t
function with a keyPrefix constrained to KeyPrefix<Ns> — most notably every
react-i18next useTranslation(ns, { keyPrefix }) caller. A leaf lookup now
resolves to a union with unrelated sibling keys' values, and large/deep
resource trees additionally hit TS2589 ("Type instantiation is excessively
deep").

// resources: app.settings.{
//   profile:       { title: 'Profile' },                  // string
//   notifications: { title: { on: 'On'; off: 'Off' } },   // object (a sibling)
// }
const { t } = useTranslation('app', { keyPrefix: 'settings.profile' });

t('title');
// 26.2.0:  'Profile'                              ✅
// 26.3.0:  'Profile' | { on: 'On'; off: 'Off' }   ❌  (sibling's title leaks in)

Reproduces under both tsc and tsgo. It does not surface through
getFixedT (which takes keyPrefix as a positional argument) or through a raw
TFunction<Ns, 'literal'> type argument — only when KPrefix is inferred
against the KeyPrefix<Ns> constraint from an options-object property, which is
exactly the react-i18next useTranslation shape.

Root cause

#2434 added [Res] extends [never] ? never : … guards to
KeysBuilderWithReturnObjects and KeysBuilderWithoutReturnObjects in
typescript/t.d.ts.

Those guards turn the builders into deferred conditional types. Because
KeyPrefix<Ns> = ResourceKeys<true>[$FirstNamespace<Ns>] flows through these
builders, KeyPrefix<Ns> stops resolving to an eager union of string-literal
key paths. When KPrefix extends KeyPrefix<Ns> is then inferred from a literal
keyPrefix, TypeScript can no longer match the literal against the deferred
constraint and falls back to the entire KeyPrefix<Ns> constraint.
AppendKeyPrefix<'title', <whole union>> then expands to every
<prefix>.title, so t('title') unions all sibling title values (and the
over-expansion blows the instantiation-depth budget on big trees).

Fix

Restore the two builders to their 26.2.0 (eager) form.

I checked whether the guards could be kept in a non-deferring shape and they
can't: the same [Res] extends [never] deferral re-breaks KeyPrefix<Ns>
whether it sits in KeysBuilder or directly on the concrete child Res[Key].
And never can't be detected without the [T] extends [never] trick, since
never extends X is always true.

The guards also turn out to be inert for the case they targeted:

  • Top-level conflicts (the only scenario covered by tests, e.g.
    shared_conflict) are already resolved by _DropConflictKeys in
    options.d.ts before the builders run — a merged namespace is never never
    (worst case {}).
  • Nested conflicts (a deep 'A' & 'B'never leaf) behave identically
    with and without the guard: same KeyPrefix<Ns>, no garbage keys, the
    conflicted sub-tree is equally poisoned either way. The guard doesn't rescue
    it.

So removing the guards changes no working scenario. If belt-and-suspenders
never-safety is ever wanted, it belongs in the merge layer (options.d.ts),
not on the ResourceKeys path — a comment in t.d.ts records this so the guard
isn't reintroduced.

Testing

  • New type-test scenario test/typescript/keyprefix-return-type/ reproducing the
    sibling-pollution case via a minimal useTranslation-shaped helper. Red on the
    guarded code, green with the fix.
  • Full npm run test:typescript suite passes (incl. registry-monorepo /
    registry-monorepo-legacy-only).

Fixes the regression introduced in #2434.

… keys

The `[Res] extends [never]` guards added to KeysBuilderWith(out)ReturnObjects
in i18next#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>
@aaronrosenthal aaronrosenthal marked this pull request as ready for review June 3, 2026 13:54
@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 94.977%. remained the same — aaronrosenthal:fix/keyprefix-return-type-regression-2434 into i18next:master

@adrai adrai merged commit 57ed812 into i18next:master Jun 3, 2026
9 checks passed
adrai added a commit that referenced this pull request Jun 3, 2026
@adrai

adrai commented Jun 3, 2026

Copy link
Copy Markdown
Member

Merged and shipped in v26.3.1. Thanks @aaronrosenthal — the write-up was excellent, especially the bit on why the [Res] extends [never] guard deferred KeyPrefix<Ns>. That root cause wasn't obvious and the warning comment you left in t.d.ts is exactly the kind of thing that saves the next person from re-introducing the same regression.

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.

3 participants