TranslationKeys stops at a plural entry instead of walking into it

A phase 0 defect that could only surface once a real catalog used a plural. The type
walked into { one, other } and contributed 'foo.one'/'foo.other' to the key union, so
t('foo', { count }) — the whole point of D7 — did not compile, while t('foo.one') did.

Found by the type checker on the first genuine plural in phase 2 (the rescan toast).
Mirrors isPluralForms at runtime: every key a CLDR category, every value a string.
Pinned with type-level @ts-expect-error assertions, since nothing else would notice.
This commit is contained in:
2026-08-23 04:38:26 +00:00
parent 36e18f935d
commit 2765e241d3
2 changed files with 41 additions and 4 deletions
+26 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test';
import { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
import { findMissingKeys } from './parity';
import type { TranslationMap } from './types';
import type { TranslationKeys, TranslationMap } from './types';
describe('resolveKey', () => {
const translations: TranslationMap = {
@@ -100,3 +100,28 @@ describe('findMissingKeys', () => {
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
});
});
/**
* Type-level, not behavioural: a plural entry must be addressable by its own key and must NOT
* contribute its CLDR categories to the union. Asserted here because nothing else would notice —
* the failure mode is `t('files', { count })` refusing to compile while `t('files.one')` compiles.
*/
describe('TranslationKeys', () => {
test('stops at a plural leaf and still walks plain nesting', () => {
const catalog = {
files: { one: '{{count}} file', other: '{{count}} files' },
button: { save: 'Save', nested: { deep: 'Deep' } },
};
type Key = TranslationKeys<typeof catalog>;
const assertKey = <K extends Key>(k: K) => k;
expect(assertKey('files')).toBe('files');
expect(assertKey('button.save')).toBe('button.save');
expect(assertKey('button.nested.deep')).toBe('button.nested.deep');
// @ts-expect-error a plural category is not a key on its own
assertKey('files.one');
// @ts-expect-error a branch node is not a key
assertKey('button');
});
});
+15 -3
View File
@@ -13,10 +13,22 @@ export type I18nContextValue = {
locale: string;
};
/**
* A node that IS a plural entry, so `TranslationKeys` stops at it rather than walking into it.
*
* Without this, `{ one, other }` contributed `foo.one` and `foo.other` to the key union and `t('foo',
* { count })` did not typecheck at all — the plural machinery landed in phase 0 with unit tests, and the
* type gap only surfaced in phase 2 when a real catalog first used one. Mirrors `isPluralForms` at
* runtime: every key a CLDR category, every value a string.
*/
type IsPluralLeaf<T> = keyof T extends Intl.LDMLPluralRule ? (T[keyof T] extends string ? true : false) : false;
export type TranslationKeys<T, Prefix extends string = ''> = T extends string
? Prefix
: T extends Record<string, unknown>
? {
[K in keyof T & string]: TranslationKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>;
}[keyof T & string]
? IsPluralLeaf<T> extends true
? Prefix
: {
[K in keyof T & string]: TranslationKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>;
}[keyof T & string]
: never;