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,