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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user