diff --git a/docs/src/pages/blog/next-intl-4-0.mdx b/docs/src/pages/blog/next-intl-4-0.mdx index 82eaa4835..cba157a96 100644 --- a/docs/src/pages/blog/next-intl-4-0.mdx +++ b/docs/src/pages/blog/next-intl-4-0.mdx @@ -119,9 +119,9 @@ t('message', {}); t('message', {}); // ^? {today: Date} -// "Market share: {value, number, percent}" +// "Page {page, number} out of {total, number}" t('message', {}); -// ^? {value: number} +// ^? {page: number, total: number} // "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}." t('message', {}); @@ -131,15 +131,27 @@ t('message', {}); t('message', {}); // ^? {country: 'US' | 'CA' | (string & {})} -// "Please refer to the guidelines." +// "Please refer to the guidelines." t('message', {}); -// ^? {guidelines: (chunks: ReactNode) => ReactNode} +// ^? {link: (chunks: ReactNode) => ReactNode} ``` -(the types in these examples are slightly simplified, e.g. a date can also be provided as a timestamp) - With this type inference in place, you can now use autocompletion in your IDE to get suggestions for the available arguments of a given ICU message and catch potential errors early. +This also addresses one of my favorite pet peeves: + +```tsx +t('followers', {count: 30000}); +``` + +```json +// ✖️ Would be: "30000 followers" +"{count} followers" + +// ✅ Valid: "30,000 followers" +"{count, number} followers" +``` + Due to a current limitation in TypeScript, this feature is opt-in for now. Please refer to the [strict arguments](/docs/workflows/typescript#messages-arguments) docs to learn how to enable it. ## GDPR compliance @@ -211,9 +223,10 @@ If things go well, I think this will finally fill in the [missing piece](https:/ 2. Inherit context in case nested `NextIntlClientProvider` instances are present (see [PR #1413](https://github.com/amannn/next-intl/pull/1413)) 3. Automatically inherit formats when `NextIntlClientProvider` is rendered from a Server Component (see [PR #1191](https://github.com/amannn/next-intl/pull/1191)) 4. Require locale to be returned from `getRequestConfig` (see [PR #1486](https://github.com/amannn/next-intl/pull/1486)) -5. Bump minimum required typescript version to 5 for projects using TypeScript (see [PR #1481](https://github.com/amannn/next-intl/pull/1481)) -6. Remove deprecated APIs (see [PR #1479](https://github.com/amannn/next-intl/pull/1479)) -7. Remove deprecated APIs pt. 2 (see [PR #1482](https://github.com/amannn/next-intl/pull/1482)) +5. Disallow passing `null`, `undefined` or `boolean` as an ICU argument (see [PR #1561](https://github.com/amannn/next-intl/pull/1561)) +6. Bump minimum required typescript version to 5 for projects using TypeScript (see [PR #1481](https://github.com/amannn/next-intl/pull/1481)) +7. Remove deprecated APIs (see [PR #1479](https://github.com/amannn/next-intl/pull/1479)) +8. Remove deprecated APIs pt. 2 (see [PR #1482](https://github.com/amannn/next-intl/pull/1482)) ## Upgrade now diff --git a/examples/example-app-router-playground/src/app/[locale]/actions/ListItem.tsx b/examples/example-app-router-playground/src/app/[locale]/actions/ListItem.tsx index df1a9e5b7..ece94e2a3 100644 --- a/examples/example-app-router-playground/src/app/[locale]/actions/ListItem.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/actions/ListItem.tsx @@ -2,5 +2,5 @@ import {useTranslations} from 'next-intl'; export default function ListItem({id}: {id: number}) { const t = useTranslations('ServerActions'); - return t('item', {id}); + return t('item', {id: String(id)}); } diff --git a/examples/example-app-router-playground/src/app/[locale]/actions/ListItemAsync.tsx b/examples/example-app-router-playground/src/app/[locale]/actions/ListItemAsync.tsx index 801919ba3..b25696f1b 100644 --- a/examples/example-app-router-playground/src/app/[locale]/actions/ListItemAsync.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/actions/ListItemAsync.tsx @@ -2,5 +2,5 @@ import {getTranslations} from 'next-intl/server'; export default async function ListItemAsync({id}: {id: number}) { const t = await getTranslations('ServerActions'); - return t('item', {id}); + return t('item', {id: String(id)}); } diff --git a/examples/example-app-router-playground/src/app/[locale]/actions/ListItemClient.tsx b/examples/example-app-router-playground/src/app/[locale]/actions/ListItemClient.tsx index b94cf8749..62d4796e1 100644 --- a/examples/example-app-router-playground/src/app/[locale]/actions/ListItemClient.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/actions/ListItemClient.tsx @@ -4,5 +4,5 @@ import {useTranslations} from 'next-intl'; export default function ListItemClient({id}: {id: number}) { const t = useTranslations('ServerActions'); - return t('item', {id}); + return t('item', {id: String(id)}); } diff --git a/examples/example-app-router-playground/src/components/Navigation.tsx b/examples/example-app-router-playground/src/components/Navigation.tsx index b4daa3660..6ad41a1ab 100644 --- a/examples/example-app-router-playground/src/components/Navigation.tsx +++ b/examples/example-app-router-playground/src/components/Navigation.tsx @@ -13,7 +13,7 @@ export default function Navigation() { - {t('newsArticle', {articleId: 3})} + {t('newsArticle', {articleId: String(3)})} ); diff --git a/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx b/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx index 51a26c5ef..e9d0bc574 100644 --- a/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx +++ b/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx @@ -8,12 +8,12 @@ import {getTranslations} from 'next-intl/server'; export function RegularComponent() { const t = useTranslations('ClientCounter'); - t('count', {count: 1}); + t('count', {count: String(1)}); // @ts-expect-error t('count'); // @ts-expect-error - t('count', {num: 1}); + t('count', {num: String(1)}); } export function CreateTranslator() { @@ -25,20 +25,20 @@ export function CreateTranslator() { namespace: 'ClientCounter' }); - t('count', {count: 1}); + t('count', {count: String(1)}); // @ts-expect-error t('count'); // @ts-expect-error - t('count', {num: 1}); + t('count', {num: String(1)}); } export async function AsyncComponent() { const t = await getTranslations('ClientCounter'); - t('count', {count: 1}); + t('count', {count: String(1)}); // @ts-expect-error t('count'); // @ts-expect-error - t('count', {num: 1}); + t('count', {num: String(1)}); } diff --git a/examples/example-app-router-playground/src/components/client/02-MessagesOnClientCounter/ClientCounter.tsx b/examples/example-app-router-playground/src/components/client/02-MessagesOnClientCounter/ClientCounter.tsx index e7a013de6..a0a75f327 100644 --- a/examples/example-app-router-playground/src/components/client/02-MessagesOnClientCounter/ClientCounter.tsx +++ b/examples/example-app-router-playground/src/components/client/02-MessagesOnClientCounter/ClientCounter.tsx @@ -13,7 +13,7 @@ export default function ClientCounter() { return (
-

{t('count', {count})}

+

{t('count', {count: String(count)})}

diff --git a/packages/use-intl/src/core/TranslationValues.tsx b/packages/use-intl/src/core/TranslationValues.tsx index 43e9bd1f7..063d3374e 100644 --- a/packages/use-intl/src/core/TranslationValues.tsx +++ b/packages/use-intl/src/core/TranslationValues.tsx @@ -1,9 +1,12 @@ import type {ReactNode} from 'react'; -export type ICUArg = string | number | boolean | Date; -// ^ Keep this in sync with `ICUArgument` in `createTranslator.tsx` - -export type TranslationValues = Record; +export type TranslationValues = Record< + string, + // All params that are allowed for basic params as well as operators like + // `plural`, `select`, `number` and `date`. Note that `Date` is not supported + // for plain params, but this requires type information from the ICU parser. + string | number | Date +>; export type RichTagsFunction = (chunks: ReactNode) => ReactNode; export type MarkupTagsFunction = (chunks: string) => string; @@ -11,9 +14,12 @@ export type MarkupTagsFunction = (chunks: string) => string; // We could consider renaming this to `ReactRichTranslationValues` and defining // it in the `react` namespace if the core becomes useful to other frameworks. // It would be a breaking change though, so let's wait for now. -export type RichTranslationValues = Record; +export type RichTranslationValues = Record< + string, + TranslationValues[string] | RichTagsFunction +>; export type MarkupTranslationValues = Record< string, - ICUArg | MarkupTagsFunction + TranslationValues[string] | MarkupTagsFunction >; diff --git a/packages/use-intl/src/core/createTranslator.test.tsx b/packages/use-intl/src/core/createTranslator.test.tsx index 218c1f236..1a4a4c9bd 100644 --- a/packages/use-intl/src/core/createTranslator.test.tsx +++ b/packages/use-intl/src/core/createTranslator.test.tsx @@ -9,6 +9,7 @@ import createTranslator from './createTranslator.tsx'; const messages = { Home: { title: 'Hello world!', + param: 'Hello {param}', rich: 'Hello {name}!', markup: 'Hello {name}!' } @@ -53,6 +54,21 @@ it('handles formatting errors', () => { expect(result).toBe('price'); }); +it('restricts boolean and date values as plain params', () => { + const onError = vi.fn(); + const t = createTranslator({ + locale: 'en', + namespace: 'Home', + messages: messages as any, + onError + }); + + t('param', {param: new Date()}); + // @ts-expect-error + t('param', {param: true}); + expect(onError.mock.calls.length).toBe(2); +}); + it('supports alphanumeric value names', () => { const t = createTranslator({ locale: 'en', @@ -234,6 +250,22 @@ describe('type safety', () => { }; }); + it('restricts non-string values', () => { + const t = translateMessage('{param}'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error -- should use {param, number} instead + t('msg', {param: 1.5}); + + // @ts-expect-error + t('msg', {param: new Date()}); + + // @ts-expect-error + t('msg', {param: true}); + }; + }); + it('can handle undefined values', () => { const t = translateMessage('Hello {name}'); @@ -266,6 +298,16 @@ describe('type safety', () => { }; }); + it('restricts numbers in dates', () => { + const t = translateMessage('Date: {date, date, full}'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {date: 1.5}); + }; + }); + it('validates cardinal plurals', () => { const t = translateMessage( 'You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}.' @@ -340,6 +382,28 @@ describe('type safety', () => { }; }); + it('restricts numbers in selects', () => { + const t = translateMessage( + '{count, select, 0 {zero} 1 {one} other {other}}' + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {count: 1.5}); + }; + }); + + it('restricts booleans in selects', () => { + const t = translateMessage('{bool, select, true {true} false {false}}'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {bool: true}); + }; + }); + it('validates escaped', () => { const t = translateMessage( "Escape curly braces with single quotes (e.g. '{name')" @@ -404,7 +468,7 @@ describe('type safety', () => { it('validates a complex message', () => { const t = translateMessage( - 'Hello {name}, you have {count, plural, =0 {no followers} =1 {one follower} other {# followers ({count})}}.' + 'Hello {name}, you have {count, plural, =0 {no followers} =1 {one follower} other {# followers ({count, number})}}.' ); t.rich('msg', { diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index e535fbf7b..eef02b5da 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -11,9 +11,9 @@ import type { NestedValueOf } from './MessageKeys.tsx'; import type { - ICUArg, MarkupTagsFunction, - RichTagsFunction + RichTagsFunction, + TranslationValues } from './TranslationValues.tsx'; import createTranslatorImpl from './createTranslatorImpl.tsx'; import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; @@ -31,12 +31,11 @@ type ICUArgsWithTags< > = ICUArgs< MessageString, { - // Provide types inline instead of an alias so the - // consumer can see the types instead of the alias - ICUArgument: string | number | boolean | Date; - // ^ Keep this in sync with `ICUArg` in `TranslationValues.tsx` + // Numbers and dates should use the corresponding operators + ICUArgument: string; + ICUNumberArgument: number; - ICUDateArgument: Date | number; + ICUDateArgument: Date; } > & ([TagsFn] extends [never] ? {} : ICUTags); @@ -49,7 +48,10 @@ type TranslateArgs< > = // If an unknown string is passed, allow any values string extends Value - ? [values?: Record, formats?: Formats] + ? [ + values?: Record, + formats?: Formats + ] : ( Value extends any ? (key: ICUArgsWithTags) => void diff --git a/packages/use-intl/src/core/index.tsx b/packages/use-intl/src/core/index.tsx index 775ab964c..4f5c6e503 100644 --- a/packages/use-intl/src/core/index.tsx +++ b/packages/use-intl/src/core/index.tsx @@ -1,7 +1,6 @@ export type {default as AbstractIntlMessages} from './AbstractIntlMessages.tsx'; export type { TranslationValues, - ICUArg, RichTranslationValues, MarkupTranslationValues, RichTagsFunction,