Skip to content

Commit

Permalink
feat: Type-safe ICU arguments (#1499)
Browse files Browse the repository at this point in the history
```json
{
  "UserProfile": {
    "title": "Hello {firstName}"
  }
}
```

```tsx
function UserProfile({user}) {
  const t = useTranslations('UserProfile');
 
  // ✖️ Missing argument
  t('title');
 
  // ✅ Argument is provided
  t('title', {firstName: user.firstName});
}
```

**Changes**
- ICU arguments can now be validated with TypeScript (however, currently
this is
[opt-in](https://next-intl-docs-git-feat-type-safe-params-next-intl.vercel.app/docs/workflows/typescript#messages-arguments))
- `undefined` and `null` are no longer accepted as values for calls to
`t`—please only provide valid values.

Fixes #410
  • Loading branch information
amannn authored Nov 7, 2024
1 parent 2388c9b commit 01268f6
Show file tree
Hide file tree
Showing 50 changed files with 1,328 additions and 815 deletions.
8 changes: 7 additions & 1 deletion docs/src/pages/docs/usage/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,12 @@ export default getRequestConfig(async ({requestLocale}) => {

```tsx filename="i18n/request.ts"
export default getRequestConfig(async () => {
// Provide a static locale, fetch a user setting,
// read from `cookies()`, `headers()`, etc.
const locale = 'en';

return {
locale: 'en'
locale
// ...
};
});
Expand Down Expand Up @@ -214,6 +218,8 @@ import {getLocale} from 'next-intl/server';
const locale = await getLocale();
```

### `Locale` [#locale-type]

When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter:

```tsx
Expand Down
245 changes: 136 additions & 109 deletions docs/src/pages/docs/workflows/typescript.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,78 @@ import Callout from '@/components/Callout';
However, you can optionally provide supplemental definitions to augment the types that `next-intl` works with, enabling improved autocompletion and type safety across your app.

```tsx filename="global.d.ts"
import {routing} from '@/i18n/routing';
import {formats} from '@/i18n/request';
import messages from './messages/en.json';

declare module 'next-intl' {
interface AppConfig {
// ...
Locale: (typeof routing.locales)[number];
Messages: typeof messages;
Formats: typeof formats;
}
}
```

Type augmentation is available for:

- [`Locale`](#locale)
- [`Messages`](#messages)
- [`Formats`](#formats)
- [`Locale`](#locale)

## `Locale`

Augmenting the `Locale` type will affect all APIs from `next-intl` that either return or receive a locale:

```tsx
import {useLocale} from 'next-intl';

// ✅ 'en' | 'de'
const locale = useLocale();
```

```tsx
import {Link} from '@/i18n/routing';

// ✅ Passes the validation
<Link href="/" locale="en" />;
```

Additionally, `next-intl` provides a [`Locale`](/docs/usage/configuration#locale-type) type that can be used when passing the locale as an argument.

To enable this validation, you can adapt `AppConfig` as follows:

<Tabs items={['With i18n routing', 'Without i18n routing']}>
<Tabs.Tab>

```tsx filename="global.d.ts"
import {routing} from '@/i18n/routing';

declare module 'next-intl' {
interface AppConfig {
// ...
Locale: (typeof routing.locales)[number];
}
}
```

</Tabs.Tab>
<Tabs.Tab>

```tsx filename="global.d.ts"
// Potentially imported from a shared config
const locales = ['en', 'de'] as const;

declare module 'next-intl' {
interface AppConfig {
// ...
Locale: (typeof locales)[number];
}
}
```

</Tabs.Tab>
</Tabs>

## `Messages`

Expand Down Expand Up @@ -50,18 +110,90 @@ function About() {
To enable this validation, you can adapt `AppConfig` as follows:

```ts filename="global.d.ts"
import en from './messages/en.json';
import messages from './messages/en.json';

declare module 'next-intl' {
interface AppConfig {
// ...
Messages: typeof en;
Messages: typeof messages;
}
}
```

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.

### Strict arguments [#messages-arguments]

Apart from strictly typing message keys, you can also ensure type safety for message arguments:

```json filename="messages/en.json"
{
"UserProfile": {
"title": "Hello {firstName}"
}
}
```

```tsx
function UserProfile({user}) {
const t = useTranslations('UserProfile');

// ✖️ Missing argument
t('title');

// ✅ Argument is provided
t('title', {firstName: user.firstName});
}
```

TypeScript currently has a [limitation](https://github.com/microsoft/TypeScript/issues/32063) where it infers the types of an imported JSON module as rather wide. Due to this, `next-intl` provides a stopgap solution that allows you to generate an accompanying `.d.json.ts` file for the messages that you're assigning to your `AppConfig`.

**Usage:**

1. Enable the `createMessagesDeclaration` setting in your Next.js config:

```tsx filename="next.config.mjs"
import {createNextIntlPlugin} from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin({
experimental: {
// Use the path to the messages that you're using in `AppConfig`
createMessagesDeclaration: './messages/en.json'
}
// ...
});

// ...
```

2. Add support for JSON type declarations in your `tsconfig.json`:

```json filename="tsconfig.json"
{
"compilerOptions": {
// ...
"allowArbitraryExtensions": true
}
}
```

With this setup in place, you'll see a new declaration file generated in your `messages` directory once you run `next dev` or `next build`:

```diff
messages/en.json
+ messages/en.d.json.ts
```

This declaration file will provide the exact types for the messages that you're using in `AppConfig`, enabling type safety for message arguments.

To keep your code base tidy, you can ignore this file in Git:

```text filename=".gitignore"
messages/*.d.json.ts
```

Please consider upvoting [`TypeScript#32063`](https://github.com/microsoft/TypeScript/issues/32063) to potentially remove this workaround in the future.

## `Formats`

If you're using [global formats](/docs/usage/configuration#formats), you can strictly type the format names that are referenced in calls to `format.dateTime`, `format.number` and `format.list`.
Expand Down Expand Up @@ -126,111 +258,6 @@ declare module 'next-intl' {
}
```

## `Locale`

Augmenting the `Locale` type will affect the return type of [`useLocale`](/docs/usage/configuration#locale), as well as all `locale` arguments that are accepted by APIs from `next-intl` (e.g. the `locale` prop of [`<Link />`](/docs/routing/navigation#link)).

```tsx
// ✅ 'en' | 'de'
const locale = useLocale();
```

To enable this validation, you can adapt `AppConfig` as follows:

<Tabs items={['With i18n routing', 'Without i18n routing']}>
<Tabs.Tab>

```tsx filename="global.d.ts"
import {routing} from '@/i18n/routing';

declare module 'next-intl' {
interface AppConfig {
// ...
Locale: (typeof routing.locales)[number];
}
}
```

</Tabs.Tab>
<Tabs.Tab>

```tsx filename="global.d.ts"
// Potentially imported from a shared config
const locales = ['en', 'de'] as const;

declare module 'next-intl' {
interface AppConfig {
// ...
Locale: (typeof locales)[number];
}
}
```

</Tabs.Tab>
</Tabs>

### Using the `Locale` type for arguments

Once the `Locale` type is augmented, it can be used across your codebase if you need to pass the locale to functions outside of your components:

```tsx {1,10}
import {Locale} from 'next-intl';
import {getLocale} from 'next-intl/server';

async function BlogPosts() {
const locale = await getLocale();
const posts = await getPosts(locale);
// ...
}

async function getPosts(locale: Locale) {
// ...
}
```

### Using the `Locale` type for layout and page params [#locale-segment-params]

You can also use the `Locale` type when working with the `[locale]` parameter in layouts and pages:

```tsx filename="app/[locale]/page.tsx"
import {Locale} from 'next-intl';

type Props = {
params: {
locale: Locale;
};
};

export default function Page(props: Props) {
// ...
}
```

However, keep in mind that this _assumes_ the locale to be valid in this place—Next.js doesn't validate the `[locale]` parameter automatically for you. Due to this, you can add your own validation logic in a central place like the root layout:

```tsx filename="app/[locale]/layout.tsx"
import {hasLocale} from 'next-intl';

// Can be imported e.g. from `@/i18n/routing`
const locales = ['en', 'de'] as const;

type Props = {
params: {
children: React.ReactNode;
locale: string;
};
};

export default async function LocaleLayout({params: {locale}}: Props) {
if (!hasLocale(locales, locale)) {
notFound();
}

// ✅ 'en' | 'de'
console.log(locale);
}
```

## Troubleshooting

If you're encountering problems, double check that:
Expand Down
4 changes: 2 additions & 2 deletions examples/example-app-router-mixed-routing/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {locales} from '@/config';
import en from './messages/en.json';
import messages from './messages/en.json';

declare module 'next-intl' {
interface AppConfig {
Locale: (typeof locales)[number];
Messages: typeof en;
Messages: typeof messages;
}
}
4 changes: 2 additions & 2 deletions examples/example-app-router-next-auth/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {routing} from '@/i18n/routing';
import en from './messages/en.json';
import messages from './messages/en.json';

declare module 'next-intl' {
interface AppConfig {
Locale: (typeof routing.locales)[number];
Messages: typeof en;
Messages: typeof messages;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ export default function Index({session}: Props) {

return (
<PageLayout title={t('title')}>
{session ? (
{session?.user?.name ? (
<>
<p>{t('loggedIn', {username: session.user?.name})}</p>
<p>{t('loggedIn', {username: session.user.name})}</p>
<p>
<Link href={locale + '/secret'}>{t('secret')}</Link>
</p>
Expand Down
1 change: 1 addition & 0 deletions examples/example-app-router-playground/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ tsconfig.tsbuildinfo
*storybook.log
storybook-static
test-results
messages/*.d.json.ts
7 changes: 6 additions & 1 deletion examples/example-app-router-playground/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {getPresets} from 'eslint-config-molindo';
import globals from 'globals';

export default await getPresets('typescript', 'react', 'jest');
export default (await getPresets('typescript', 'react', 'jest')).concat({
languageOptions: {
globals: globals.node
}
});
4 changes: 2 additions & 2 deletions examples/example-app-router-playground/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {formats} from '@/i18n/request';
import {routing} from '@/i18n/routing';
import en from './messages/en.json';
import messages from './messages/en.json';

declare module 'next-intl' {
interface AppConfig {
Locale: (typeof routing.locales)[number];
Formats: typeof formats;
Messages: typeof en;
Messages: typeof messages;
}
}
7 changes: 6 additions & 1 deletion examples/example-app-router-playground/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
import mdxPlugin from '@next/mdx';
import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin('./src/i18n/request.tsx');
const withNextIntl = createNextIntlPlugin({
requestConfig: './src/i18n/request.tsx',
experimental: {
createMessagesDeclaration: './messages/en.json'
}
});
const withMdx = mdxPlugin();

export default withMdx(
Expand Down
1 change: 1 addition & 0 deletions examples/example-app-router-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"css-loader": "^6.8.1",
"eslint": "^9.11.1",
"eslint-config-molindo": "^8.0.0",
"globals": "^15.11.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"prettier": "^3.3.3",
Expand Down
Loading

0 comments on commit 01268f6

Please sign in to comment.