From fb9c0c8fa7d22a7f7f6402d010b2103814eb3d9b Mon Sep 17 00:00:00 2001 From: hardlight Date: Mon, 23 Mar 2026 22:54:56 +0100 Subject: [PATCH] fix: type checking for union message types to only allow keys present in all union members --- docs/src/pages/docs/workflows/typescript.mdx | 15 ++++ packages/use-intl/src/core/MessageKeys.tsx | 16 +++-- .../src/core/createTranslator.test.tsx | 69 +++++++++++++++++++ 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/docs/src/pages/docs/workflows/typescript.mdx b/docs/src/pages/docs/workflows/typescript.mdx index e6a4e383b..68c8a42f9 100644 --- a/docs/src/pages/docs/workflows/typescript.mdx +++ b/docs/src/pages/docs/workflows/typescript.mdx @@ -129,6 +129,21 @@ declare module 'next-intl' { } ``` +If some of your locales have different keys (e.g., a key exists in `en.json` but is missing in `es.json`), you can use a union type to catch these inconsistencies at the call site. +With this setup, only keys that are present in **all** locale files are accessible via the translator — using a key missing in any locale will result in a TypeScript error. + +```ts filename="global.ts" +import messagesEn from './messages/en.json'; +import messagesEs from './messages/es.json'; + +declare module 'next-intl' { + interface AppConfig { + // ... + Messages: typeof messagesEn | typeof messagesEs; + } +} +``` + You can freely define the interface, but if you have your messages available locally, it can be helpful to automatically create the type based on the messages from your default locale.
diff --git a/packages/use-intl/src/core/MessageKeys.tsx b/packages/use-intl/src/core/MessageKeys.tsx index 40667bbab..2f6628d65 100644 --- a/packages/use-intl/src/core/MessageKeys.tsx +++ b/packages/use-intl/src/core/MessageKeys.tsx @@ -1,9 +1,9 @@ -export type NestedKeyOf = ObjectType extends object +export type NestedKeyOf = [ObjectType] extends [object] ? { - [Property in keyof ObjectType]: - | `${Property & string}` - | `${Property & string}.${NestedKeyOf}`; - }[keyof ObjectType] + [Property in keyof ObjectType & string]: + | Property + | `${Property}.${NestedKeyOf}`; + }[keyof ObjectType & string] : never; export type NestedValueOf< @@ -30,7 +30,9 @@ export type MessageKeys = { [PropertyPath in AllKeys]: NestedValueOf< ObjectType, PropertyPath - > extends string - ? PropertyPath + > extends infer V + ? V extends string + ? PropertyPath + : never : never; }[AllKeys]; diff --git a/packages/use-intl/src/core/createTranslator.test.tsx b/packages/use-intl/src/core/createTranslator.test.tsx index 41fbc0e39..7ba3de2e0 100644 --- a/packages/use-intl/src/core/createTranslator.test.tsx +++ b/packages/use-intl/src/core/createTranslator.test.tsx @@ -228,6 +228,75 @@ describe('type safety', () => { }); }); + describe('keys, union messages type', () => { + type MessagesEn = {meta: {title: 'title'; description: 'description'}}; + type MessagesEs = {meta: {title: 'title-es'}}; + type MessagesUnion = MessagesEn | MessagesEs; + + it('allows keys present in all union members', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + const t = createTranslator({ + locale: 'en', + messages: {meta: {title: 'title', description: 'description'}} + }); + t('meta.title'); + + const tMeta = createTranslator({ + locale: 'en', + namespace: 'meta', + messages: {meta: {title: 'title', description: 'description'}} + }); + tMeta('title'); + }; + }); + + it('requires params from all union members when a key has different args', () => { + type MessagesWithParamsEn = {title: 'Hello {name}'}; + type MessagesWithParamsEs = {title: 'Hola {count, number}'}; + type MessagesWithParamsUnion = + | MessagesWithParamsEn + | MessagesWithParamsEs; + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + const t = createTranslator({ + locale: 'en', + messages: {title: 'Hello {name}'} + }); + + // Providing all params from all union members is valid + t('title', {name: 'John', count: 5}); + + // Providing only some params is an error + // @ts-expect-error + t('title', {name: 'John'}); + // @ts-expect-error + t('title', {count: 5}); + }; + }); + + it('disallows keys missing in some union members', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + const t = createTranslator({ + locale: 'en', + messages: {meta: {title: 'title', description: 'description'}} + }); + // @ts-expect-error + t('meta.description'); + + const tMeta = createTranslator({ + locale: 'en', + namespace: 'meta', + messages: {meta: {title: 'title', description: 'description'}} + }); + // @ts-expect-error + tMeta('description'); + }; + }); + }); + describe('params, strictly-typed', () => { function translateMessage(msg: T) { return createTranslator({