Skip to content

Commit

Permalink
feat!: Disallow passing null, undefined or boolean as an ICU ar…
Browse files Browse the repository at this point in the history
…gument (#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)});
```
  • Loading branch information
amannn authored Nov 20, 2024
1 parent 0b2c951 commit 6275030
Show file tree
Hide file tree
Showing 11 changed files with 120 additions and 36 deletions.
31 changes: 22 additions & 9 deletions docs/src/pages/blog/next-intl-4-0.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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', {});
Expand All @@ -131,15 +131,27 @@ t('message', {});
t('message', {});
// ^? {country: 'US' | 'CA' | (string & {})}

// "Please refer to <guidelines>the guidelines</guidelines>."
// "Please refer to the <link>guidelines</link>."
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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)});
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default function Navigation() {
<NavigationLink
href={{pathname: '/news/[articleId]', params: {articleId: 3}}}
>
{t('newsArticle', {articleId: 3})}
{t('newsArticle', {articleId: String(3)})}
</NavigationLink>
</nav>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)});
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default function ClientCounter() {

return (
<div data-testid="MessagesOnClientCounter">
<p>{t('count', {count})}</p>
<p>{t('count', {count: String(count)})}</p>
<button onClick={onIncrement} type="button">
{t('increment')}
</button>
Expand Down
18 changes: 12 additions & 6 deletions packages/use-intl/src/core/TranslationValues.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
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<string, ICUArg>;
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;

// 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<string, ICUArg | RichTagsFunction>;
export type RichTranslationValues = Record<
string,
TranslationValues[string] | RichTagsFunction
>;

export type MarkupTranslationValues = Record<
string,
ICUArg | MarkupTagsFunction
TranslationValues[string] | MarkupTagsFunction
>;
66 changes: 65 additions & 1 deletion packages/use-intl/src/core/createTranslator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import createTranslator from './createTranslator.tsx';
const messages = {
Home: {
title: 'Hello world!',
param: 'Hello {param}',
rich: '<b>Hello <i>{name}</i>!</b>',
markup: '<b>Hello <i>{name}</i>!</b>'
}
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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}');

Expand Down Expand Up @@ -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}}.'
Expand Down Expand Up @@ -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')"
Expand Down Expand Up @@ -404,7 +468,7 @@ describe('type safety', () => {

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

t.rich('msg', {
Expand Down
18 changes: 10 additions & 8 deletions packages/use-intl/src/core/createTranslator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<MessageString, TagsFn>);
Expand All @@ -49,7 +48,10 @@ type TranslateArgs<
> =
// If an unknown string is passed, allow any values
string extends Value
? [values?: Record<string, ICUArg | TagsFn>, formats?: Formats]
? [
values?: Record<string, TranslationValues[string] | TagsFn>,
formats?: Formats
]
: (
Value extends any
? (key: ICUArgsWithTags<Value, TagsFn>) => void
Expand Down
1 change: 0 additions & 1 deletion packages/use-intl/src/core/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export type {default as AbstractIntlMessages} from './AbstractIntlMessages.tsx';
export type {
TranslationValues,
ICUArg,
RichTranslationValues,
MarkupTranslationValues,
RichTagsFunction,
Expand Down

0 comments on commit 6275030

Please sign in to comment.