Skip to content

Commit 6275030

Browse files
authored
feat!: Disallow passing null, undefined or boolean as an ICU argument (#1561)
These are errors now: ```tsx t('message', {value: null}); t('message', {value: undefined}); t('message', {value: false}); ``` If you really want to put a raw boolean value in a message, you can cast it to a string first: ```tsx const value = true; t('message', {value: String(value)}); ```
1 parent 0b2c951 commit 6275030

File tree

11 files changed

+120
-36
lines changed

11 files changed

+120
-36
lines changed

docs/src/pages/blog/next-intl-4-0.mdx

+22-9
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,9 @@ t('message', {});
119119
t('message', {});
120120
// ^? {today: Date}
121121

122-
// "Market share: {value, number, percent}"
122+
// "Page {page, number} out of {total, number}"
123123
t('message', {});
124-
// ^? {value: number}
124+
// ^? {page: number, total: number}
125125

126126
// "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}."
127127
t('message', {});
@@ -131,15 +131,27 @@ t('message', {});
131131
t('message', {});
132132
// ^? {country: 'US' | 'CA' | (string & {})}
133133

134-
// "Please refer to <guidelines>the guidelines</guidelines>."
134+
// "Please refer to the <link>guidelines</link>."
135135
t('message', {});
136-
// ^? {guidelines: (chunks: ReactNode) => ReactNode}
136+
// ^? {link: (chunks: ReactNode) => ReactNode}
137137
```
138138

139-
(the types in these examples are slightly simplified, e.g. a date can also be provided as a timestamp)
140-
141139
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.
142140

141+
This also addresses one of my favorite pet peeves:
142+
143+
```tsx
144+
t('followers', {count: 30000});
145+
```
146+
147+
```json
148+
// ✖️ Would be: "30000 followers"
149+
"{count} followers"
150+
151+
// ✅ Valid: "30,000 followers"
152+
"{count, number} followers"
153+
```
154+
143155
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.
144156

145157
## GDPR compliance
@@ -211,9 +223,10 @@ If things go well, I think this will finally fill in the [missing piece](https:/
211223
2. Inherit context in case nested `NextIntlClientProvider` instances are present (see [PR #1413](https://github.com/amannn/next-intl/pull/1413))
212224
3. Automatically inherit formats when `NextIntlClientProvider` is rendered from a Server Component (see [PR #1191](https://github.com/amannn/next-intl/pull/1191))
213225
4. Require locale to be returned from `getRequestConfig` (see [PR #1486](https://github.com/amannn/next-intl/pull/1486))
214-
5. Bump minimum required typescript version to 5 for projects using TypeScript (see [PR #1481](https://github.com/amannn/next-intl/pull/1481))
215-
6. Remove deprecated APIs (see [PR #1479](https://github.com/amannn/next-intl/pull/1479))
216-
7. Remove deprecated APIs pt. 2 (see [PR #1482](https://github.com/amannn/next-intl/pull/1482))
226+
5. Disallow passing `null`, `undefined` or `boolean` as an ICU argument (see [PR #1561](https://github.com/amannn/next-intl/pull/1561))
227+
6. Bump minimum required typescript version to 5 for projects using TypeScript (see [PR #1481](https://github.com/amannn/next-intl/pull/1481))
228+
7. Remove deprecated APIs (see [PR #1479](https://github.com/amannn/next-intl/pull/1479))
229+
8. Remove deprecated APIs pt. 2 (see [PR #1482](https://github.com/amannn/next-intl/pull/1482))
217230

218231
## Upgrade now
219232

examples/example-app-router-playground/src/app/[locale]/actions/ListItem.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ import {useTranslations} from 'next-intl';
22

33
export default function ListItem({id}: {id: number}) {
44
const t = useTranslations('ServerActions');
5-
return t('item', {id});
5+
return t('item', {id: String(id)});
66
}

examples/example-app-router-playground/src/app/[locale]/actions/ListItemAsync.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ import {getTranslations} from 'next-intl/server';
22

33
export default async function ListItemAsync({id}: {id: number}) {
44
const t = await getTranslations('ServerActions');
5-
return t('item', {id});
5+
return t('item', {id: String(id)});
66
}

examples/example-app-router-playground/src/app/[locale]/actions/ListItemClient.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ import {useTranslations} from 'next-intl';
44

55
export default function ListItemClient({id}: {id: number}) {
66
const t = useTranslations('ServerActions');
7-
return t('item', {id});
7+
return t('item', {id: String(id)});
88
}

examples/example-app-router-playground/src/components/Navigation.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default function Navigation() {
1313
<NavigationLink
1414
href={{pathname: '/news/[articleId]', params: {articleId: 3}}}
1515
>
16-
{t('newsArticle', {articleId: 3})}
16+
{t('newsArticle', {articleId: String(3)})}
1717
</NavigationLink>
1818
</nav>
1919
);

examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx

+6-6
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ import {getTranslations} from 'next-intl/server';
88

99
export function RegularComponent() {
1010
const t = useTranslations('ClientCounter');
11-
t('count', {count: 1});
11+
t('count', {count: String(1)});
1212

1313
// @ts-expect-error
1414
t('count');
1515
// @ts-expect-error
16-
t('count', {num: 1});
16+
t('count', {num: String(1)});
1717
}
1818

1919
export function CreateTranslator() {
@@ -25,20 +25,20 @@ export function CreateTranslator() {
2525
namespace: 'ClientCounter'
2626
});
2727

28-
t('count', {count: 1});
28+
t('count', {count: String(1)});
2929

3030
// @ts-expect-error
3131
t('count');
3232
// @ts-expect-error
33-
t('count', {num: 1});
33+
t('count', {num: String(1)});
3434
}
3535

3636
export async function AsyncComponent() {
3737
const t = await getTranslations('ClientCounter');
38-
t('count', {count: 1});
38+
t('count', {count: String(1)});
3939

4040
// @ts-expect-error
4141
t('count');
4242
// @ts-expect-error
43-
t('count', {num: 1});
43+
t('count', {num: String(1)});
4444
}

examples/example-app-router-playground/src/components/client/02-MessagesOnClientCounter/ClientCounter.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default function ClientCounter() {
1313

1414
return (
1515
<div data-testid="MessagesOnClientCounter">
16-
<p>{t('count', {count})}</p>
16+
<p>{t('count', {count: String(count)})}</p>
1717
<button onClick={onIncrement} type="button">
1818
{t('increment')}
1919
</button>
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import type {ReactNode} from 'react';
22

3-
export type ICUArg = string | number | boolean | Date;
4-
// ^ Keep this in sync with `ICUArgument` in `createTranslator.tsx`
5-
6-
export type TranslationValues = Record<string, ICUArg>;
3+
export type TranslationValues = Record<
4+
string,
5+
// All params that are allowed for basic params as well as operators like
6+
// `plural`, `select`, `number` and `date`. Note that `Date` is not supported
7+
// for plain params, but this requires type information from the ICU parser.
8+
string | number | Date
9+
>;
710

811
export type RichTagsFunction = (chunks: ReactNode) => ReactNode;
912
export type MarkupTagsFunction = (chunks: string) => string;
1013

1114
// We could consider renaming this to `ReactRichTranslationValues` and defining
1215
// it in the `react` namespace if the core becomes useful to other frameworks.
1316
// It would be a breaking change though, so let's wait for now.
14-
export type RichTranslationValues = Record<string, ICUArg | RichTagsFunction>;
17+
export type RichTranslationValues = Record<
18+
string,
19+
TranslationValues[string] | RichTagsFunction
20+
>;
1521

1622
export type MarkupTranslationValues = Record<
1723
string,
18-
ICUArg | MarkupTagsFunction
24+
TranslationValues[string] | MarkupTagsFunction
1925
>;

packages/use-intl/src/core/createTranslator.test.tsx

+65-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import createTranslator from './createTranslator.tsx';
99
const messages = {
1010
Home: {
1111
title: 'Hello world!',
12+
param: 'Hello {param}',
1213
rich: '<b>Hello <i>{name}</i>!</b>',
1314
markup: '<b>Hello <i>{name}</i>!</b>'
1415
}
@@ -53,6 +54,21 @@ it('handles formatting errors', () => {
5354
expect(result).toBe('price');
5455
});
5556

57+
it('restricts boolean and date values as plain params', () => {
58+
const onError = vi.fn();
59+
const t = createTranslator({
60+
locale: 'en',
61+
namespace: 'Home',
62+
messages: messages as any,
63+
onError
64+
});
65+
66+
t('param', {param: new Date()});
67+
// @ts-expect-error
68+
t('param', {param: true});
69+
expect(onError.mock.calls.length).toBe(2);
70+
});
71+
5672
it('supports alphanumeric value names', () => {
5773
const t = createTranslator({
5874
locale: 'en',
@@ -234,6 +250,22 @@ describe('type safety', () => {
234250
};
235251
});
236252

253+
it('restricts non-string values', () => {
254+
const t = translateMessage('{param}');
255+
256+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
257+
() => {
258+
// @ts-expect-error -- should use {param, number} instead
259+
t('msg', {param: 1.5});
260+
261+
// @ts-expect-error
262+
t('msg', {param: new Date()});
263+
264+
// @ts-expect-error
265+
t('msg', {param: true});
266+
};
267+
});
268+
237269
it('can handle undefined values', () => {
238270
const t = translateMessage('Hello {name}');
239271

@@ -266,6 +298,16 @@ describe('type safety', () => {
266298
};
267299
});
268300

301+
it('restricts numbers in dates', () => {
302+
const t = translateMessage('Date: {date, date, full}');
303+
304+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
305+
() => {
306+
// @ts-expect-error
307+
t('msg', {date: 1.5});
308+
};
309+
});
310+
269311
it('validates cardinal plurals', () => {
270312
const t = translateMessage(
271313
'You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}.'
@@ -340,6 +382,28 @@ describe('type safety', () => {
340382
};
341383
});
342384

385+
it('restricts numbers in selects', () => {
386+
const t = translateMessage(
387+
'{count, select, 0 {zero} 1 {one} other {other}}'
388+
);
389+
390+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
391+
() => {
392+
// @ts-expect-error
393+
t('msg', {count: 1.5});
394+
};
395+
});
396+
397+
it('restricts booleans in selects', () => {
398+
const t = translateMessage('{bool, select, true {true} false {false}}');
399+
400+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
401+
() => {
402+
// @ts-expect-error
403+
t('msg', {bool: true});
404+
};
405+
});
406+
343407
it('validates escaped', () => {
344408
const t = translateMessage(
345409
"Escape curly braces with single quotes (e.g. '{name')"
@@ -404,7 +468,7 @@ describe('type safety', () => {
404468

405469
it('validates a complex message', () => {
406470
const t = translateMessage(
407-
'Hello <user>{name}</user>, you have {count, plural, =0 {no followers} =1 {one follower} other {# followers ({count})}}.'
471+
'Hello <user>{name}</user>, you have {count, plural, =0 {no followers} =1 {one follower} other {# followers ({count, number})}}.'
408472
);
409473

410474
t.rich('msg', {

packages/use-intl/src/core/createTranslator.tsx

+10-8
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import type {
1111
NestedValueOf
1212
} from './MessageKeys.tsx';
1313
import type {
14-
ICUArg,
1514
MarkupTagsFunction,
16-
RichTagsFunction
15+
RichTagsFunction,
16+
TranslationValues
1717
} from './TranslationValues.tsx';
1818
import createTranslatorImpl from './createTranslatorImpl.tsx';
1919
import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx';
@@ -31,12 +31,11 @@ type ICUArgsWithTags<
3131
> = ICUArgs<
3232
MessageString,
3333
{
34-
// Provide types inline instead of an alias so the
35-
// consumer can see the types instead of the alias
36-
ICUArgument: string | number | boolean | Date;
37-
// ^ Keep this in sync with `ICUArg` in `TranslationValues.tsx`
34+
// Numbers and dates should use the corresponding operators
35+
ICUArgument: string;
36+
3837
ICUNumberArgument: number;
39-
ICUDateArgument: Date | number;
38+
ICUDateArgument: Date;
4039
}
4140
> &
4241
([TagsFn] extends [never] ? {} : ICUTags<MessageString, TagsFn>);
@@ -49,7 +48,10 @@ type TranslateArgs<
4948
> =
5049
// If an unknown string is passed, allow any values
5150
string extends Value
52-
? [values?: Record<string, ICUArg | TagsFn>, formats?: Formats]
51+
? [
52+
values?: Record<string, TranslationValues[string] | TagsFn>,
53+
formats?: Formats
54+
]
5355
: (
5456
Value extends any
5557
? (key: ICUArgsWithTags<Value, TagsFn>) => void

packages/use-intl/src/core/index.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
export type {default as AbstractIntlMessages} from './AbstractIntlMessages.tsx';
22
export type {
33
TranslationValues,
4-
ICUArg,
54
RichTranslationValues,
65
MarkupTranslationValues,
76
RichTagsFunction,

0 commit comments

Comments
 (0)