Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: Type-safe ICU arguments #1499

Merged
merged 46 commits into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
9e19c4b
move type utils in use-intl to shared folder
amannn Nov 1, 2024
bd0a017
add some tests
amannn Nov 1, 2024
108962e
small fix
amannn Nov 1, 2024
82eafba
more tests
amannn Nov 1, 2024
34d6ec4
simplify
amannn Nov 1, 2024
877b246
simplify
amannn Nov 1, 2024
930c94a
unnecessary describe
amannn Nov 1, 2024
509670a
Revert "unnecessary describe"
amannn Nov 1, 2024
91b5396
test suite
amannn Nov 1, 2024
8c4b8ca
fix eslint issue
amannn Nov 1, 2024
f184383
… a bit of progress 🔥
amannn Nov 1, 2024
ce0e500
rich and markup too
amannn Nov 1, 2024
e7fcbee
more tests
amannn Nov 1, 2024
a6fbd55
ignore
amannn Nov 1, 2024
dabb5b7
separate file for keys
amannn Nov 1, 2024
383148c
more complex example
amannn Nov 1, 2024
afcb4ef
default export
amannn Nov 1, 2024
b9301fd
a few tests
amannn Nov 1, 2024
c1a2e5e
more tests
amannn Nov 1, 2024
7e9288d
adapt tests
amannn Nov 1, 2024
1198153
some progress
amannn Nov 1, 2024
21fbaae
more tests
amannn Nov 1, 2024
378eed6
some progress
amannn Nov 1, 2024
b403b4a
simpler
amannn Nov 1, 2024
b968186
schummar impl
amannn Nov 4, 2024
b1fabdd
comments
amannn Nov 4, 2024
5f56fea
some cleanup
amannn Nov 5, 2024
54cdcef
simplify tests
amannn Nov 5, 2024
5c906e9
failing test
amannn Nov 5, 2024
f21a376
refactor plugin to be more extensible
amannn Nov 5, 2024
e6330aa
`compileMessagesDeclaration` feature
amannn Nov 5, 2024
b1e8fa8
use plugin in playground
amannn Nov 5, 2024
70a74e6
fix lint
amannn Nov 5, 2024
3004416
use in example-app-router
amannn Nov 5, 2024
58e87d0
add a banner
amannn Nov 5, 2024
99d1b2a
prettier autocomplete
amannn Nov 5, 2024
4e990ef
fix lint
amannn Nov 5, 2024
aadd7aa
rename option, improve docs
amannn Nov 5, 2024
ca1e81a
rename
amannn Nov 5, 2024
ba82b43
cleanup
amannn Nov 5, 2024
dc9c59c
fix lint
amannn Nov 5, 2024
d0e5f07
fix example
amannn Nov 5, 2024
d7b341d
rename imported messages
amannn Nov 5, 2024
e5bcf34
sort typescript augmentation sections
amannn Nov 5, 2024
44f59a7
improve docs
amannn Nov 7, 2024
8f86516
update comment [skip ci]
amannn Nov 7, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading