diff --git a/src/workspaces/i18n/src/resolve.test.ts b/src/workspaces/i18n/src/resolve.test.ts index 4338050c..f6777c92 100644 --- a/src/workspaces/i18n/src/resolve.test.ts +++ b/src/workspaces/i18n/src/resolve.test.ts @@ -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; + + const assertKey = (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'); + }); +}); diff --git a/src/workspaces/i18n/src/types.ts b/src/workspaces/i18n/src/types.ts index 4d4626fe..95453c72 100644 --- a/src/workspaces/i18n/src/types.ts +++ b/src/workspaces/i18n/src/types.ts @@ -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 = keyof T extends Intl.LDMLPluralRule ? (T[keyof T] extends string ? true : false) : false; + export type TranslationKeys = T extends string ? Prefix : T extends Record - ? { - [K in keyof T & string]: TranslationKeys; - }[keyof T & string] + ? IsPluralLeaf extends true + ? Prefix + : { + [K in keyof T & string]: TranslationKeys; + }[keyof T & string] : never;