diff --git a/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx b/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx index 01f8eb579..16a926ac1 100644 --- a/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx +++ b/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx @@ -154,8 +154,9 @@ Note that by default, `next-intl` returns [the `link` response header](/docs/rou Next.js supports providing alternate URLs per language via the [`alternates` entry](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap#generate-a-localized-sitemap) as of version 14.2. You can use your default locale for the main URL and provide alternate URLs based on all locales that your app supports. Keep in mind that also the default locale should be included in the `alternates` object. -```tsx filename="app/sitemap.ts" {4-5,8-9} +```tsx filename="app/sitemap.ts" {5-6,9-10} import {MetadataRoute} from 'next'; +import {Locale} from 'next-intl'; import {routing, getPathname} from '@/i18n/routing'; // Adapt this as necessary @@ -179,7 +180,7 @@ function getEntry(href: Href) { }; } -function getUrl(href: Href, locale: (typeof routing.locales)[number]) { +function getUrl(href: Href, locale: Locale) { const pathname = getPathname({locale, href}); return host + pathname; } @@ -206,12 +207,16 @@ You can use `next-intl` in [Route Handlers](https://nextjs.org/docs/app/building ```tsx filename="app/api/hello/route.tsx" import {NextResponse} from 'next/server'; +import {hasLocale} from 'next-intl'; import {getTranslations} from 'next-intl/server'; export async function GET(request) { // Example: Receive the `locale` via a search param const {searchParams} = new URL(request.url); const locale = searchParams.get('locale'); + if (!hasLocale(locales, locale)) { + return NextResponse.json({error: 'Invalid locale'}, {status: 400}); + } const t = await getTranslations({locale, namespace: 'Hello'}); return NextResponse.json({title: t('title')}); diff --git a/docs/src/pages/docs/environments/error-files.mdx b/docs/src/pages/docs/environments/error-files.mdx index 90da28c7c..9ac7e5233 100644 --- a/docs/src/pages/docs/environments/error-files.mdx +++ b/docs/src/pages/docs/environments/error-files.mdx @@ -77,14 +77,15 @@ export default function RootLayout({children}) { } ``` -For the 404 page to render, we need to call the `notFound` function in the root layout when we detect an incoming `locale` param that isn't a valid locale. +For the 404 page to render, we need to call the `notFound` function in the root layout when we detect an incoming `locale` param that isn't valid. ```tsx filename="app/[locale]/layout.tsx" +import {hasLocale} from 'next-intl'; import {notFound} from 'next/navigation'; +import {routing} from '@/i18n/routing'; export default function LocaleLayout({children, params: {locale}}) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } diff --git a/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx b/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx index 6456eeb48..d2d8a4498 100644 --- a/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx +++ b/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx @@ -147,16 +147,15 @@ When using features from `next-intl` in Server Components, the relevant configur ```tsx filename="src/i18n/request.ts" import {getRequestConfig} from 'next-intl/server'; +import {hasLocale} from 'next-intl'; import {routing} from './routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that a valid locale is used - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; return { locale, @@ -186,7 +185,7 @@ const withNextIntl = createNextIntlPlugin( The `locale` that was matched by the middleware is available via the `locale` param and can be used to configure the document language. Additionally, we can use this place to pass configuration from `i18n/request.ts` to Client Components via `NextIntlClientProvider`. ```tsx filename="app/[locale]/layout.tsx" -import {NextIntlClientProvider} from 'next-intl'; +import {NextIntlClientProvider, Locale, hasLocale} from 'next-intl'; import {getMessages} from 'next-intl/server'; import {notFound} from 'next/navigation'; import {routing} from '@/i18n/routing'; @@ -196,10 +195,9 @@ export default async function LocaleLayout({ params: {locale} }: { children: React.ReactNode; - params: {locale: string}; + params: {locale: Locale}; }) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } @@ -297,12 +295,12 @@ export function generateStaticParams() { ```tsx filename="app/[locale]/layout.tsx" import {setRequestLocale} from 'next-intl/server'; +import {hasLocale} from 'next-intl'; import {notFound} from 'next/navigation'; import {routing} from '@/i18n/routing'; export default async function LocaleLayout({children, params: {locale}}) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } diff --git a/docs/src/pages/docs/routing.mdx b/docs/src/pages/docs/routing.mdx index 6dffa60c9..b79c35a38 100644 --- a/docs/src/pages/docs/routing.mdx +++ b/docs/src/pages/docs/routing.mdx @@ -297,11 +297,12 @@ In case you're using a system like a CMS to configure localized pathnames, you'l ```tsx filename="page.tsx" import {notFound} from 'next'; +import {Locale} from 'next-intl'; import {fetchContent} from './cms'; type Props = { params: { - locale: string; + locale: Locale; slug: Array; }; }; diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index dd2055702..574542e8a 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -15,51 +15,21 @@ Depending on if you handle [internationalization in Server- or Client Components `i18n/request.ts` can be used to provide configuration for **server-only** code, i.e. Server Components, Server Actions & friends. The configuration is provided via the `getRequestConfig` function and needs to be set up based on whether you're using [i18n routing](/docs/getting-started/app-router) or not. - - - - ```tsx filename="i18n/request.ts" import {getRequestConfig} from 'next-intl/server'; import {routing} from '@/i18n/routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment. - let locale = await requestLocale; - - // Ensure that a valid locale is used - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // ... return { locale, - messages: (await import(`../../messages/${locale}.json`)).default - }; -}); -``` - - - - -```tsx filename="i18n/request.ts" -import {getRequestConfig} from 'next-intl/server'; - -export default getRequestConfig(async () => { - // Provide a static locale, fetch a user setting, - // read from `cookies()`, `headers()`, etc. - const locale = 'en'; - - return { - locale, - messages: (await import(`../../messages/${locale}.json`)).default + messages + // ... }; }); ``` - - - The configuration object is created once for each request by internally using React's [`cache`](https://react.dev/reference/react/cache). The first component to use internationalization will call the function defined with `getRequestConfig`. Since this function is executed during the Server Components render pass, you can call functions like [`cookies()`](https://nextjs.org/docs/app/api-reference/functions/cookies) and [`headers()`](https://nextjs.org/docs/app/api-reference/functions/headers) to return configuration that is request-specific. @@ -80,17 +50,6 @@ const withNextIntl = createNextIntlPlugin( -
-Which values can the `requestLocale` parameter hold? - -While the `requestLocale` parameter typically corresponds to the `[locale]` segment that was matched by the middleware, there are three special cases to consider: - -1. **Overrides**: When an explicit `locale` is passed to [awaitable functions](/docs/environments/actions-metadata-route-handlers) like `getTranslations({locale: 'en'})`, then this value will be used instead of the segment. -1. **`undefined`**: The value can be `undefined` when a page outside of the `[locale]` segment renders (e.g. a language selection page at `app/page.tsx`). -1. **Invalid values**: Since the `[locale]` segment effectively acts like a catch-all for unknown routes (e.g. `/unknown.txt`), invalid values should be replaced with a valid locale. In addition to this, you might want to call `notFound()` in [the root layout](/docs/getting-started/app-router/with-i18n-routing#layout) to abort the render in this case. - -
- ### `NextIntlClientProvider` `NextIntlClientProvider` can be used to provide configuration for **Client Components**. @@ -100,9 +59,7 @@ import {NextIntlClientProvider} from 'next-intl'; import {getMessages} from 'next-intl/server'; export default async function RootLayout(/* ... */) { - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); + // ... return ( @@ -183,6 +140,137 @@ Note that the inner `NextIntlClientProvider` inherits the configuration from the +## Locale + +The `locale` represents an identifier that contains the language and formatting preferences of users, optionally including regional information (e.g. `en-US`). Locales are specified as [IETF BCP 47 language tags](https://en.wikipedia.org/wiki/IETF_language_tag). + + + + +Depending on if you're using [i18n routing](/docs/getting-started/app-router), you can read the locale from the `requestLocale` parameter or provide a value on your own: + +**With i18n routing:** + +```tsx filename="i18n/request.ts" +export default getRequestConfig(async ({requestLocale}) => { + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; + + return { + locale + // ... + }; +}); +``` + +**Without i18n routing:** + +```tsx filename="i18n/request.ts" +export default getRequestConfig(async () => { + return { + locale: 'en' + // ... + }; +}); +``` + + + + +```tsx +... +``` + + + + +
+Which values can the `requestLocale` parameter hold? + +While the `requestLocale` parameter typically corresponds to the `[locale]` segment that was matched by the middleware, there are three special cases to consider: + +1. **Overrides**: When an explicit `locale` is passed to [awaitable functions](/docs/environments/actions-metadata-route-handlers) like `getTranslations({locale: 'en'})`, then this value will be used instead of the segment. +1. **`undefined`**: The value can be `undefined` when a page outside of the `[locale]` segment renders (e.g. a language selection page at `app/page.tsx`). +1. **Invalid values**: Since the `[locale]` segment effectively acts like a catch-all for unknown routes (e.g. `/unknown.txt`), invalid values should be replaced with a valid locale. In addition to this, you might want to call `notFound()` in [the root layout](/docs/getting-started/app-router/with-i18n-routing#layout) to abort the render in this case. + +
+ +### `useLocale` & `getLocale` [#use-locale] + +The current locale of your app is automatically incorporated into hooks like `useTranslations` & `useFormatter` and will affect the rendered output. + +In case you need to use this value in other places of your app, e.g. to implement a locale switcher or to pass it to API calls, you can read it via `useLocale` or `getLocale`: + +```tsx +// Regular components +import {useLocale} from 'next-intl'; +const locale = useLocale(); + +// Async Server Components +import {getLocale} from 'next-intl/server'; +const locale = await getLocale(); +``` + +When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter: + +```tsx +import {Locale} from 'next-intl'; + +async function getPosts(locale: Locale) { + // ... +} +``` + + + By default, `Locale` is typed as `string`. However, you can optionally provide + a strict union based on your supported locales for this type by [augmenting + the `Locale` type](/docs/workflows/typescript#locale). + + +
+How can I change the locale? + +Depending on if you're using [i18n routing](/docs/getting-started/app-router), the locale can be changed as follows: + +1. **With i18n routing**: The locale is managed by the router and can be changed by using navigation APIs from `next-intl` like [`Link`](/docs/routing/navigation#link) or [`useRouter`](/docs/routing/navigation#userouter). +2. **Without i18n routing**: You can change the locale by updating the value where the locale is read from (e.g. a cookie, a user setting, etc.). If you're looking for inspiration, you can have a look at the [App Router without i18n routing example](/examples#app-router-without-i18n-routing) that manages the locale via a cookie. + +
+ +
+Which value is returned from `useLocale`? + +The returned value is resolved based on these priorities: + +1. **Server Components**: If you're using [i18n routing](/docs/getting-started/app-router), the returned locale is the one that you've either provided via [`setRequestLocale`](/docs/getting-started/app-router/with-i18n-routing#static-rendering) or alternatively the one in the `[locale]` segment that was matched by the middleware. If you're not using i18n routing, the returned locale is the one that you've provided via `getRequestConfig`. +2. **Client Components**: In this case, the locale is received from `NextIntlClientProvider` or alternatively `useParams().locale`. Note that `NextIntlClientProvider` automatically inherits the locale if the component is rendered by a Server Component. + +
+ +
+I'm using the Pages Router, how can I access the locale? + +If you use [internationalized routing with the Pages Router](https://nextjs.org/docs/pages/building-your-application/routing/internationalization), you can receive the locale from the router in order to pass it to `NextIntlClientProvider`: + +```tsx filename="_app.tsx" +import {useRouter} from 'next/router'; + +// ... + +const router = useRouter(); + +return ( + + ... + ; +); +``` + +
+ ## Messages The most crucial aspect of internationalization is providing labels based on the user's language. The recommended workflow is to store your messages in your repository along with the code. @@ -215,40 +303,6 @@ export default getRequestConfig(async () => { After messages are configured, they can be used via [`useTranslations`](/docs/usage/messages#rendering-messages-with-usetranslations). -In case you require access to messages in a component, you can read them via `useMessages()` or `getMessages()` from your configuration: - -```tsx -// Regular components -import {useMessages} from 'next-intl'; -const messages = useMessages(); - -// Async Server Components -import {getMessages} from 'next-intl/server'; -const messages = await getMessages(); -``` - - - - -```tsx -import {NextIntlClientProvider} from 'next-intl'; -import {getMessages} from 'next-intl/server'; - -async function Component({children}) { - // Read messages configured via `i18n/request.ts` - const messages = await getMessages(); - - return ( - - {children} - - ); -} -``` - - - -
How can I load messages from remote sources? @@ -298,6 +352,42 @@ Note that [the VSCode integration for `next-intl`](/docs/workflows/vscode-integr
+### `useMessages` & `getMessages` [#use-messages] + +In case you require access to messages in a component, you can read them via `useMessages()` or `getMessages()` from your configuration: + +```tsx +// Regular components +import {useMessages} from 'next-intl'; +const messages = useMessages(); + +// Async Server Components +import {getMessages} from 'next-intl/server'; +const messages = await getMessages(); +``` + + + + +```tsx +import {NextIntlClientProvider} from 'next-intl'; +import {getMessages} from 'next-intl/server'; + +async function Component({children}) { + // Read messages configured via `i18n/request.ts` + const messages = await getMessages(); + + return ( + + {children} + + ); +} +``` + + + + ## Time zone Specifying a time zone affects the rendering of dates and times. By default, the time zone of the server runtime will be used, but can be customized as necessary. @@ -337,6 +427,10 @@ const timeZone = 'Europe/Vienna'; The available time zone names can be looked up in [the tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). +The time zone in Client Components is automatically inherited from the server side if you wrap the relevant components in a `NextIntlClientProvider` that is rendered by a Server Component. For all other cases, you can specify the value explicitly on a wrapping `NextIntlClientProvider`. + +### `useTimeZone` & `getTimeZone` [#use-time-zone] + The configured time zone can be read via `useTimeZone` or `getTimeZone` in components: ```tsx @@ -349,11 +443,6 @@ import {getTimeZone} from 'next-intl/server'; const timeZone = await getTimeZone(); ``` -The time zone in Client Components is automatically inherited from the server -side if you wrap the relevant components in a `NextIntlClientProvider` that is -rendered by a Server Component. For all other cases, you can specify the value -explicitly on a wrapping `NextIntlClientProvider`. - ## Now value [#now] When formatting [relative dates and times](/docs/usage/dates-times#formatting-relative-time), `next-intl` will format times in relation to a reference point in time that is referred to as "now". By default, this is the time a component renders. @@ -391,6 +480,10 @@ const now = new Date('2020-11-20T10:36:01.516Z'); +Similarly to the `timeZone`, the `now` value in Client Components is automatically inherited from the server side if you wrap the relevant components in a `NextIntlClientProvider` that is rendered by a Server Component. + +### `useNow` & `getNow` [#use-now] + The configured `now` value can be read in components via `useNow` or `getNow`: ```tsx @@ -403,11 +496,6 @@ import {getNow} from 'next-intl/server'; const now = await getNow(); ``` -Similarly to the `timeZone`, the `now` value in Client Components is -automatically inherited from the server side if you wrap the relevant -components in a `NextIntlClientProvider` that is rendered by a Server -Component. - ## Formats To achieve consistent date, time, number and list formatting, you can define a set of global formats. @@ -446,8 +534,6 @@ export default getRequestConfig(async () => { }); ``` -Note that `formats` are not automatically inherited by Client Components. If you want to make this available in Client Components, you should provide the same configuration to [`NextIntlClientProvider`](#nextintlclientprovider). - @@ -496,9 +582,9 @@ function Component() { ``` - You can optionally [specify a global type for - `formats`](/docs/workflows/typescript#formats) to get autocompletion and type - safety. + By default, format names are loosely typed as `string`. However, you can + optionally use strict types by [augmenting the `Formats` + type](/docs/workflows/typescript#formats). Global formats for numbers, dates and times can be referenced in messages too: @@ -601,61 +687,3 @@ function getMessageFallback({namespace, key, error}) { - -## Locale - -The current locale of your app is automatically incorporated into hooks like `useTranslations` & `useFormatter` and will affect the rendered output. - -In case you need to use this value in other places of your app, e.g. to implement a locale switcher or to pass it to API calls, you can read it via `useLocale` or `getLocale`: - -```tsx -// Regular components -import {useLocale} from 'next-intl'; -const locale = useLocale(); - -// Async Server Components -import {getLocale} from 'next-intl/server'; -const locale = await getLocale(); -``` - -
-How can I change the locale? - -Depending on if you're using [i18n routing](/docs/getting-started/app-router), the locale can be changed as follows: - -1. **With i18n routing**: The locale is managed by the router and can be changed by using navigation APIs from `next-intl` like [`Link`](/docs/routing/navigation#link) or [`useRouter`](/docs/routing/navigation#userouter). -2. **Without i18n routing**: You can change the locale by updating the value where the locale is read from (e.g. a cookie, a user setting, etc.). If you're looking for inspiration, you can have a look at the [App Router without i18n routing example](/examples#app-router-without-i18n-routing) that manages the locale via a cookie. - -
- -
-Which value is returned from `useLocale`? - -The returned value is resolved based on these priorities: - -1. **Server Components**: If you're using [i18n routing](/docs/getting-started/app-router), the returned locale is the one that you've either provided via [`setRequestLocale`](/docs/getting-started/app-router/with-i18n-routing#static-rendering) or alternatively the one in the `[locale]` segment that was matched by the middleware. If you're not using i18n routing, the returned locale is the one that you've provided via `getRequestConfig`. -2. **Client Components**: In this case, the locale is received from `NextIntlClientProvider` or alternatively `useParams().locale`. Note that `NextIntlClientProvider` automatically inherits the locale if the component is rendered by a Server Component. For all other cases, you can specify the value - explicitly. - -
- -
-I'm using the Pages Router, how can I access the locale? - -If you use [internationalized routing with the Pages Router](https://nextjs.org/docs/pages/building-your-application/routing/internationalization), you can receive the locale from the router in order to pass it to `NextIntlClientProvider`: - -```tsx filename="_app.tsx" -import {useRouter} from 'next/router'; - -// ... - -const router = useRouter(); - -return ( - - ... - ; -); -``` - -
diff --git a/docs/src/pages/docs/usage/messages.mdx b/docs/src/pages/docs/usage/messages.mdx index f2d5233c6..e88421d9b 100644 --- a/docs/src/pages/docs/usage/messages.mdx +++ b/docs/src/pages/docs/usage/messages.mdx @@ -8,7 +8,7 @@ The main part of handling internationalization (typically referred to as _i18n_) ## Terminology -- **Locale**: We use this term to describe an identifier that contains the language and formatting preferences of users. Apart from the language, a locale can include optional regional information (e.g. `en-US`). Locales are specified as [IETF BCP 47 language tags](https://en.wikipedia.org/wiki/IETF_language_tag). +- **Locale**: We use this term to describe an identifier that contains the language and formatting preferences of users. Apart from the language, a locale can include optional regional information (e.g. `en-US`). - **Messages**: These are collections of namespace-label pairs that are grouped by locale (e.g. `en-US.json`). ## Structuring messages diff --git a/docs/src/pages/docs/workflows.mdx b/docs/src/pages/docs/workflows.mdx index 746ad13d5..89b597c1c 100644 --- a/docs/src/pages/docs/workflows.mdx +++ b/docs/src/pages/docs/workflows.mdx @@ -3,10 +3,10 @@ import Cards from '@/components/Cards'; # Workflows & integrations -To get the most out of `next-intl`, you can choose from these integrations to improve your workflow when developing and collaborating with translators. +To get the most out of `next-intl`, you can choose from these integrations to improve your workflow. - + - The [TypeScript integration of `next-intl`](/docs/workflows/typescript) can + The [TypeScript augmentation of `next-intl`](/docs/workflows/typescript) can help you to validate at compile time that your app is in sync with your translation bundles. diff --git a/docs/src/pages/docs/workflows/typescript.mdx b/docs/src/pages/docs/workflows/typescript.mdx index 3c6eae8c0..878d5003d 100644 --- a/docs/src/pages/docs/workflows/typescript.mdx +++ b/docs/src/pages/docs/workflows/typescript.mdx @@ -1,12 +1,28 @@ +import Details from '@/components/Details'; +import {Tabs} from 'nextra/components'; import Callout from '@/components/Callout'; -# TypeScript integration +# TypeScript augmentation `next-intl` integrates seamlessly with TypeScript right out of the box, requiring no additional setup. -However, you can optionally provide supplemental type definitions for your messages and formats to enable autocompletion and improve type safety. +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. -## Messages +```tsx filename="global.d.ts" +declare module 'next-intl' { + interface AppConfig { + // ... + } +} +``` + +Type augmentation is available for: + +- [`Messages`](#messages) +- [`Formats`](#formats) +- [`Locale`](#locale) + +## `Messages` Messages can be strictly typed to ensure you're using valid keys. @@ -31,44 +47,44 @@ function About() { } ``` -To enable this validation, add a global type definition file in your project root (e.g. `global.d.ts`): +To enable this validation, you can adapt `AppConfig` as follows: ```ts filename="global.d.ts" import en from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + // ... + Messages: typeof en; + } } ``` -You can freely define the interface, but if you have your messages available locally, it can be helpful to automatically create the interface based on the messages from your default locale by importing it. +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. -## Formats +## `Formats` -[Global formats](/docs/usage/configuration#formats) that are referenced in calls like `format.dateTime` can be strictly typed to ensure you're using valid format names across your app. +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`. ```tsx function Component() { const format = useFormatter(); - // ✅ Valid format - format.number(2, 'precise'); - - // ✅ Valid format - format.list(['HTML', 'CSS', 'JavaScript'], 'enumeration'); - // ✖️ Unknown format string format.dateTime(new Date(), 'unknown'); // ✅ Valid format format.dateTime(new Date(), 'short'); + + // ✅ Valid format + format.number(2, 'precise'); + + // ✅ Valid format + format.list(['HTML', 'CSS', 'JavaScript'], 'enumeration'); } ``` -To enable this validation, export the formats that you're using in your request configuration: +To enable this validation, export the formats that you're using e.g. from your request configuration: ```ts filename="i18n/request.ts" import {Formats} from 'next-intl'; @@ -97,16 +113,121 @@ export const formats = { // ... ``` -Now, a global type definition file in the root of your project can pick up the shape of your formats and use them for declaring the `IntlFormats` interface: +Now, you can include the `formats` in your `AppConfig`: ```ts filename="global.d.ts" -import {formats} from './src/i18n/request'; +import {formats} from '@/i18n/request'; + +declare module 'next-intl' { + interface AppConfig { + // ... + Formats: typeof formats; + } +} +``` + +## `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 [``](/docs/routing/navigation#link)). + +```tsx +// ✅ 'en' | 'de' +const locale = useLocale(); +``` + +To enable this validation, you can adapt `AppConfig` as follows: + + + + +```tsx filename="global.d.ts" +import {routing} from '@/i18n/routing'; -type Formats = typeof formats; +declare module 'next-intl' { + interface AppConfig { + // ... + Locale: (typeof routing.locales)[number]; + } +} +``` + + + + +```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]; + } +} +``` + + + + +### 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(); + } -declare global { - // Use type safe formats with `next-intl` - interface IntlFormats extends Formats {} + // ✅ 'en' | 'de' + console.log(locale); } ``` @@ -114,7 +235,7 @@ declare global { If you're encountering problems, double check that: -1. Your interface uses the correct name. +1. The interface uses the correct name `AppConfig`. 2. You're using correct paths for all modules you're importing into your global declaration file. 3. Your type declaration file is included in `tsconfig.json`. -4. Your editor has loaded the most recent type declarations. When in doubt, you can restart. +4. Your editor has loaded the latest types. When in doubt, restart your editor. diff --git a/examples/example-app-router-migration/src/app/[locale]/layout.tsx b/examples/example-app-router-migration/src/app/[locale]/layout.tsx index 6864db0de..5f131d8c9 100644 --- a/examples/example-app-router-migration/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-migration/src/app/[locale]/layout.tsx @@ -1,5 +1,5 @@ import {notFound} from 'next/navigation'; -import {NextIntlClientProvider} from 'next-intl'; +import {NextIntlClientProvider, hasLocale} from 'next-intl'; import {getMessages} from 'next-intl/server'; import {ReactNode} from 'react'; import {routing} from '@/i18n/routing'; @@ -10,8 +10,7 @@ type Props = { }; export default async function LocaleLayout({children, params}: Props) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(params.locale as any)) { + if (!hasLocale(routing.locales, params.locale)) { notFound(); } diff --git a/examples/example-app-router-migration/src/i18n/request.ts b/examples/example-app-router-migration/src/i18n/request.ts index 70066e964..370fc6d0c 100644 --- a/examples/example-app-router-migration/src/i18n/request.ts +++ b/examples/example-app-router-migration/src/i18n/request.ts @@ -1,14 +1,13 @@ +import {hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that the incoming locale is valid - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; return { locale, diff --git a/examples/example-app-router-mixed-routing/global.d.ts b/examples/example-app-router-mixed-routing/global.d.ts index b749518b9..604dfbb40 100644 --- a/examples/example-app-router-mixed-routing/global.d.ts +++ b/examples/example-app-router-mixed-routing/global.d.ts @@ -1,8 +1,9 @@ +import {locales} from '@/config'; import en from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Locale: (typeof locales)[number]; + Messages: typeof en; + } } diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/PublicNavigationLocaleSwitcher.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/PublicNavigationLocaleSwitcher.tsx index 45ce1900d..986e6e93d 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/PublicNavigationLocaleSwitcher.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/PublicNavigationLocaleSwitcher.tsx @@ -1,7 +1,6 @@ 'use client'; -import {useLocale} from 'next-intl'; -import {Locale} from '@/config'; +import {Locale, useLocale} from 'next-intl'; import {Link, usePathname} from '@/i18n/routing.public'; export default function PublicNavigationLocaleSwitcher() { diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx index 260a990c6..ffe4b971b 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx @@ -1,9 +1,9 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import PageTitle from '@/components/PageTitle'; type Props = { - params: {locale: string}; + params: {locale: Locale}; }; export default function About({params: {locale}}: Props) { diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx index 258c83e5f..cde039f11 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx @@ -1,6 +1,6 @@ import {Metadata} from 'next'; import {notFound} from 'next/navigation'; -import {NextIntlClientProvider} from 'next-intl'; +import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl'; import {getMessages, setRequestLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import Document from '@/components/Document'; @@ -10,7 +10,7 @@ import PublicNavigationLocaleSwitcher from './PublicNavigationLocaleSwitcher'; type Props = { children: ReactNode; - params: {locale: string}; + params: {locale: Locale}; }; export function generateStaticParams() { @@ -25,14 +25,14 @@ export default async function LocaleLayout({ children, params: {locale} }: Props) { - // Enable static rendering - setRequestLocale(locale); - // Ensure that the incoming locale is valid - if (!locales.includes(locale as any)) { + if (!hasLocale(locales, locale)) { notFound(); } + // Enable static rendering + setRequestLocale(locale); + // Providing all messages to the client // side is the easiest way to get started const messages = await getMessages(); diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx index f3ee4cbf4..a12206760 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx @@ -1,9 +1,9 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import PageTitle from '@/components/PageTitle'; type Props = { - params: {locale: string}; + params: {locale: Locale}; }; export default function Index({params: {locale}}: Props) { diff --git a/examples/example-app-router-mixed-routing/src/app/app/AppNavigationLocaleSwitcher.tsx b/examples/example-app-router-mixed-routing/src/app/app/AppNavigationLocaleSwitcher.tsx index 9101443dc..f85bc3c10 100644 --- a/examples/example-app-router-mixed-routing/src/app/app/AppNavigationLocaleSwitcher.tsx +++ b/examples/example-app-router-mixed-routing/src/app/app/AppNavigationLocaleSwitcher.tsx @@ -1,8 +1,7 @@ 'use client'; import {useRouter} from 'next/navigation'; -import {useLocale} from 'next-intl'; -import {Locale} from '@/config'; +import {Locale, useLocale} from 'next-intl'; import updateLocale from './updateLocale'; export default function AppNavigationLocaleSwitcher() { diff --git a/examples/example-app-router-mixed-routing/src/config.ts b/examples/example-app-router-mixed-routing/src/config.ts index d71700814..e7b729aa0 100644 --- a/examples/example-app-router-mixed-routing/src/config.ts +++ b/examples/example-app-router-mixed-routing/src/config.ts @@ -1,5 +1,5 @@ +import {Locale} from 'next-intl'; + export const locales = ['en', 'de'] as const; export const defaultLocale: Locale = 'en'; - -export type Locale = (typeof locales)[number]; diff --git a/examples/example-app-router-mixed-routing/src/db.ts b/examples/example-app-router-mixed-routing/src/db.ts index 266e9e6a9..bd327e120 100644 --- a/examples/example-app-router-mixed-routing/src/db.ts +++ b/examples/example-app-router-mixed-routing/src/db.ts @@ -1,5 +1,6 @@ import {cookies} from 'next/headers'; -import {defaultLocale} from './config'; +import {Locale, hasLocale} from 'next-intl'; +import {defaultLocale, locales} from './config'; // This cookie name is used by `next-intl` on the public pages too. By // reading/writing to this locale, we can ensure that the user's locale @@ -8,8 +9,9 @@ import {defaultLocale} from './config'; // that instead when the user is logged in. const COOKIE_NAME = 'NEXT_LOCALE'; -export async function getUserLocale() { - return cookies().get(COOKIE_NAME)?.value || defaultLocale; +export async function getUserLocale(): Promise { + const candidate = cookies().get(COOKIE_NAME)?.value; + return hasLocale(locales, candidate) ? candidate : defaultLocale; } export async function setUserLocale(locale: string) { diff --git a/examples/example-app-router-mixed-routing/src/i18n/request.ts b/examples/example-app-router-mixed-routing/src/i18n/request.ts index ff3845b6c..15748b733 100644 --- a/examples/example-app-router-mixed-routing/src/i18n/request.ts +++ b/examples/example-app-router-mixed-routing/src/i18n/request.ts @@ -1,20 +1,17 @@ +import {hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import {defaultLocale, locales} from '../config'; import {getUserLocale} from '../db'; export default getRequestConfig(async ({requestLocale}) => { // Read from potential `[locale]` segment - let locale = await requestLocale; + let candidate = await requestLocale; - if (!locale) { + if (!candidate) { // The user is logged in - locale = await getUserLocale(); - } - - // Ensure that the incoming locale is valid - if (!locales.includes(locale as any)) { - locale = defaultLocale; + candidate = await getUserLocale(); } + const locale = hasLocale(locales, candidate) ? candidate : defaultLocale; return { locale, diff --git a/examples/example-app-router-next-auth/global.d.ts b/examples/example-app-router-next-auth/global.d.ts index b749518b9..62bfc23e3 100644 --- a/examples/example-app-router-next-auth/global.d.ts +++ b/examples/example-app-router-next-auth/global.d.ts @@ -1,8 +1,9 @@ +import {routing} from '@/i18n/routing'; import en from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Locale: (typeof routing.locales)[number]; + Messages: typeof en; + } } diff --git a/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx b/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx index 0acc48b28..c8df9eb83 100644 --- a/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx @@ -1,20 +1,19 @@ import {notFound} from 'next/navigation'; -import {NextIntlClientProvider} from 'next-intl'; +import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl'; import {getMessages} from 'next-intl/server'; import {ReactNode} from 'react'; import {routing} from '@/i18n/routing'; type Props = { children: ReactNode; - params: {locale: string}; + params: {locale: Locale}; }; export default async function LocaleLayout({ children, params: {locale} }: Props) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } diff --git a/examples/example-app-router-next-auth/src/i18n/request.ts b/examples/example-app-router-next-auth/src/i18n/request.ts index 70066e964..370fc6d0c 100644 --- a/examples/example-app-router-next-auth/src/i18n/request.ts +++ b/examples/example-app-router-next-auth/src/i18n/request.ts @@ -1,14 +1,13 @@ +import {hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that the incoming locale is valid - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; return { locale, diff --git a/examples/example-app-router-playground/global.d.ts b/examples/example-app-router-playground/global.d.ts index 15004afe0..277003def 100644 --- a/examples/example-app-router-playground/global.d.ts +++ b/examples/example-app-router-playground/global.d.ts @@ -1,13 +1,11 @@ +import {formats} from '@/i18n/request'; +import {routing} from '@/i18n/routing'; import en from './messages/en.json'; -import {formats} from './src/i18n/request'; -type Messages = typeof en; -type Formats = typeof formats; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} - - // Use type safe formats with `next-intl` - interface IntlFormats extends Formats {} +declare module 'next-intl' { + interface AppConfig { + Locale: (typeof routing.locales)[number]; + Formats: typeof formats; + Messages: typeof en; + } } diff --git a/examples/example-app-router-playground/src/app/[locale]/about/page.tsx b/examples/example-app-router-playground/src/app/[locale]/about/page.tsx index cf16681e1..5f9e2a5cb 100644 --- a/examples/example-app-router-playground/src/app/[locale]/about/page.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/about/page.tsx @@ -1,6 +1,8 @@ +import {Locale} from 'next-intl'; + type Props = { params: { - locale: string; + locale: Locale; }; }; diff --git a/examples/example-app-router-playground/src/app/[locale]/api/route.ts b/examples/example-app-router-playground/src/app/[locale]/api/route.ts index 921971072..890078add 100644 --- a/examples/example-app-router-playground/src/app/[locale]/api/route.ts +++ b/examples/example-app-router-playground/src/app/[locale]/api/route.ts @@ -1,9 +1,10 @@ import {NextRequest, NextResponse} from 'next/server'; +import {Locale} from 'next-intl'; import {getTranslations} from 'next-intl/server'; type Props = { params: { - locale: string; + locale: Locale; }; }; diff --git a/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx b/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx index fae9dea09..90c291ab3 100644 --- a/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx @@ -1,6 +1,6 @@ 'use client'; -import {useFormatter, useLocale, useNow, useTimeZone} from 'next-intl'; +import {useLocale, useNow, useTimeZone} from 'next-intl'; import {Link, usePathname} from '@/i18n/routing'; export default function ClientContent() { @@ -18,23 +18,3 @@ export default function ClientContent() { ); } - -export function TypeTest() { - const format = useFormatter(); - - format.dateTime(new Date(), 'medium'); - // @ts-expect-error - format.dateTime(new Date(), 'unknown'); - - format.dateTimeRange(new Date(), new Date(), 'medium'); - // @ts-expect-error - format.dateTimeRange(new Date(), new Date(), 'unknown'); - - format.number(420, 'precise'); - // @ts-expect-error - format.number(420, 'unknown'); - - format.list(['this', 'is', 'a', 'list'], 'enumeration'); - // @ts-expect-error - format.list(['this', 'is', 'a', 'list'], 'unknown'); -} diff --git a/examples/example-app-router-playground/src/app/[locale]/layout.tsx b/examples/example-app-router-playground/src/app/[locale]/layout.tsx index a46626c9d..d709cf78f 100644 --- a/examples/example-app-router-playground/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/layout.tsx @@ -1,5 +1,6 @@ import {Metadata} from 'next'; import {notFound} from 'next/navigation'; +import {Locale, hasLocale} from 'next-intl'; import { getFormatter, getNow, @@ -12,7 +13,7 @@ import Navigation from '../../components/Navigation'; type Props = { children: ReactNode; - params: {locale: string}; + params: {locale: Locale}; }; export async function generateMetadata({ @@ -29,14 +30,13 @@ export async function generateMetadata({ description: t('description'), other: { currentYear: formatter.dateTime(now, {year: 'numeric'}), - timeZone: timeZone || 'N/A' + timeZone } }; } export default function LocaleLayout({children, params: {locale}}: Props) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } diff --git a/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx b/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx index 7b080c397..010fd66e5 100644 --- a/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx @@ -1,6 +1,6 @@ import {Metadata} from 'next'; -import {useTranslations} from 'next-intl'; -import {Locale, getPathname} from '@/i18n/routing'; +import {Locale, useTranslations} from 'next-intl'; +import {getPathname} from '@/i18n/routing'; type Props = { params: { diff --git a/examples/example-app-router-playground/src/app/[locale]/opengraph-image.tsx b/examples/example-app-router-playground/src/app/[locale]/opengraph-image.tsx index 763972462..ffa13ed2e 100644 --- a/examples/example-app-router-playground/src/app/[locale]/opengraph-image.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/opengraph-image.tsx @@ -1,9 +1,10 @@ import {ImageResponse} from 'next/og'; +import {Locale} from 'next-intl'; import {getTranslations} from 'next-intl/server'; type Props = { params: { - locale: string; + locale: Locale; }; }; diff --git a/examples/example-app-router-playground/src/components/AsyncComponent.tsx b/examples/example-app-router-playground/src/components/AsyncComponent.tsx index 94a624238..aa4b17029 100644 --- a/examples/example-app-router-playground/src/components/AsyncComponent.tsx +++ b/examples/example-app-router-playground/src/components/AsyncComponent.tsx @@ -1,4 +1,4 @@ -import {getFormatter, getTranslations} from 'next-intl/server'; +import {getTranslations} from 'next-intl/server'; export default async function AsyncComponent() { const t = await getTranslations('AsyncComponent'); @@ -20,8 +20,6 @@ export default async function AsyncComponent() { export async function TypeTest() { const t = await getTranslations('AsyncComponent'); - const format = await getFormatter(); - // @ts-expect-error await getTranslations('Unknown'); @@ -36,20 +34,4 @@ export async function TypeTest() { // @ts-expect-error t.has('unknown'); - - format.dateTime(new Date(), 'medium'); - // @ts-expect-error - format.dateTime(new Date(), 'unknown'); - - format.dateTimeRange(new Date(), new Date(), 'medium'); - // @ts-expect-error - format.dateTimeRange(new Date(), new Date(), 'unknown'); - - format.number(420, 'precise'); - // @ts-expect-error - format.number(420, 'unknown'); - - format.list(['this', 'is', 'a', 'list'], 'enumeration'); - // @ts-expect-error - format.list(['this', 'is', 'a', 'list'], 'unknown'); } diff --git a/examples/example-app-router-playground/src/components/UseFormatterTypeTests.tsx b/examples/example-app-router-playground/src/components/UseFormatterTypeTests.tsx new file mode 100644 index 000000000..c81ebfb43 --- /dev/null +++ b/examples/example-app-router-playground/src/components/UseFormatterTypeTests.tsx @@ -0,0 +1,44 @@ +import {useFormatter} from 'next-intl'; +import {getFormatter} from 'next-intl/server'; + +export function RegularComponent() { + const format = useFormatter(); + + format.dateTime(new Date(), 'medium'); + format.dateTime(new Date(), 'long'); + // @ts-expect-error + format.dateTime(new Date(), 'unknown'); + + format.dateTimeRange(new Date(), new Date(), 'medium'); + // @ts-expect-error + format.dateTimeRange(new Date(), new Date(), 'unknown'); + + format.number(420, 'precise'); + // @ts-expect-error + format.number(420, 'unknown'); + + format.list(['this', 'is', 'a', 'list'], 'enumeration'); + // @ts-expect-error + format.list(['this', 'is', 'a', 'list'], 'unknown'); +} + +export async function AsyncComponent() { + const format = await getFormatter(); + + format.dateTime(new Date(), 'medium'); + format.dateTime(new Date(), 'long'); + // @ts-expect-error + format.dateTime(new Date(), 'unknown'); + + format.dateTimeRange(new Date(), new Date(), 'medium'); + // @ts-expect-error + format.dateTimeRange(new Date(), new Date(), 'unknown'); + + format.number(420, 'precise'); + // @ts-expect-error + format.number(420, 'unknown'); + + format.list(['this', 'is', 'a', 'list'], 'enumeration'); + // @ts-expect-error + format.list(['this', 'is', 'a', 'list'], 'unknown'); +} diff --git a/examples/example-app-router-playground/src/components/UseLocaleTypeTests.tsx b/examples/example-app-router-playground/src/components/UseLocaleTypeTests.tsx new file mode 100644 index 000000000..5030bb594 --- /dev/null +++ b/examples/example-app-router-playground/src/components/UseLocaleTypeTests.tsx @@ -0,0 +1,62 @@ +import {Locale, useLocale} from 'next-intl'; +import {getLocale} from 'next-intl/server'; +import {Link, getPathname, redirect, useRouter} from '@/i18n/routing'; + +export function RegularComponent() { + const locale = useLocale(); + + locale satisfies Locale; + 'en' satisfies typeof locale; + + // @ts-expect-error + 'fr' satisfies typeof locale; + // @ts-expect-error + 2 satisfies typeof locale; + + const router = useRouter(); + router.push('/', {locale}); + + return ( + <> + + Home + + {getPathname({ + href: '/', + locale + })} + {redirect({ + href: '/', + locale + })} + + ); +} + +export async function AsyncComponent() { + const locale = await getLocale(); + + locale satisfies Locale; + 'en' satisfies typeof locale; + + // @ts-expect-error + 'fr' satisfies typeof locale; + // @ts-expect-error + 2 satisfies typeof locale; + + return ( + <> + + Home + + {getPathname({ + href: '/', + locale + })} + {redirect({ + href: '/', + locale + })} + + ); +} diff --git a/examples/example-app-router-playground/src/components/MessagesTest.tsx b/examples/example-app-router-playground/src/components/UseMessagesTypeTests.tsx similarity index 87% rename from examples/example-app-router-playground/src/components/MessagesTest.tsx rename to examples/example-app-router-playground/src/components/UseMessagesTypeTests.tsx index 88f725260..fb9d850fa 100644 --- a/examples/example-app-router-playground/src/components/MessagesTest.tsx +++ b/examples/example-app-router-playground/src/components/UseMessagesTypeTests.tsx @@ -2,7 +2,7 @@ import {useMessages} from 'next-intl'; import {getMessages} from 'next-intl/server'; -export async function GetMessages() { +export async function AsyncComponent() { const messages = await getMessages(); // Valid @@ -16,7 +16,7 @@ export async function GetMessages() { messages.Index.unknown; } -export function UseMessages() { +export function RegularComponent() { const messages = useMessages(); // Valid diff --git a/examples/example-app-router-playground/src/i18n/request.tsx b/examples/example-app-router-playground/src/i18n/request.tsx index d132c19e2..db7a1b01d 100644 --- a/examples/example-app-router-playground/src/i18n/request.tsx +++ b/examples/example-app-router-playground/src/i18n/request.tsx @@ -1,5 +1,5 @@ import {headers} from 'next/headers'; -import {Formats} from 'next-intl'; +import {Formats, hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import defaultMessages from '../../messages/en.json'; import {routing} from './routing'; @@ -10,6 +10,11 @@ export const formats = { dateStyle: 'medium', timeStyle: 'short', hour12: false + }, + long: { + dateStyle: 'full', + timeStyle: 'long', + hour12: false } }, number: { @@ -26,13 +31,11 @@ export const formats = { } satisfies Formats; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that the incoming locale is valid - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; const now = headers().get('x-now'); const timeZone = headers().get('x-time-zone') ?? 'Europe/Vienna'; diff --git a/examples/example-app-router-playground/src/i18n/routing.ts b/examples/example-app-router-playground/src/i18n/routing.ts index 4232fffa7..1da45abec 100644 --- a/examples/example-app-router-playground/src/i18n/routing.ts +++ b/examples/example-app-router-playground/src/i18n/routing.ts @@ -60,8 +60,5 @@ export const routing = defineRouting({ } }); -export type Pathnames = keyof typeof routing.pathnames; -export type Locale = (typeof routing.locales)[number]; - export const {Link, getPathname, redirect, usePathname, useRouter} = createNavigation(routing); diff --git a/examples/example-app-router/global.d.ts b/examples/example-app-router/global.d.ts index b749518b9..62bfc23e3 100644 --- a/examples/example-app-router/global.d.ts +++ b/examples/example-app-router/global.d.ts @@ -1,8 +1,9 @@ +import {routing} from '@/i18n/routing'; import en from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Locale: (typeof routing.locales)[number]; + Messages: typeof en; + } } diff --git a/examples/example-app-router/src/app/[locale]/layout.tsx b/examples/example-app-router/src/app/[locale]/layout.tsx index 2a517d046..7dc3091ae 100644 --- a/examples/example-app-router/src/app/[locale]/layout.tsx +++ b/examples/example-app-router/src/app/[locale]/layout.tsx @@ -1,4 +1,5 @@ import {notFound} from 'next/navigation'; +import {Locale, hasLocale} from 'next-intl'; import {getTranslations, setRequestLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import BaseLayout from '@/components/BaseLayout'; @@ -6,7 +7,7 @@ import {routing} from '@/i18n/routing'; type Props = { children: ReactNode; - params: {locale: string}; + params: {locale: Locale}; }; export function generateStaticParams() { @@ -27,8 +28,7 @@ export default async function LocaleLayout({ children, params: {locale} }: Props) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } diff --git a/examples/example-app-router/src/app/[locale]/page.tsx b/examples/example-app-router/src/app/[locale]/page.tsx index a5b6ad8ef..ea963c340 100644 --- a/examples/example-app-router/src/app/[locale]/page.tsx +++ b/examples/example-app-router/src/app/[locale]/page.tsx @@ -1,9 +1,9 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import PageLayout from '@/components/PageLayout'; type Props = { - params: {locale: string}; + params: {locale: Locale}; }; export default function IndexPage({params: {locale}}: Props) { diff --git a/examples/example-app-router/src/app/[locale]/pathnames/page.tsx b/examples/example-app-router/src/app/[locale]/pathnames/page.tsx index 943615b11..b53ab8535 100644 --- a/examples/example-app-router/src/app/[locale]/pathnames/page.tsx +++ b/examples/example-app-router/src/app/[locale]/pathnames/page.tsx @@ -1,9 +1,9 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import PageLayout from '@/components/PageLayout'; type Props = { - params: {locale: string}; + params: {locale: Locale}; }; export default function PathnamesPage({params: {locale}}: Props) { diff --git a/examples/example-app-router/src/app/sitemap.ts b/examples/example-app-router/src/app/sitemap.ts index 0fefe33a2..7f021f249 100644 --- a/examples/example-app-router/src/app/sitemap.ts +++ b/examples/example-app-router/src/app/sitemap.ts @@ -1,6 +1,7 @@ import {MetadataRoute} from 'next'; +import {Locale} from 'next-intl'; import {host} from '@/config'; -import {Locale, getPathname, routing} from '@/i18n/routing'; +import {getPathname, routing} from '@/i18n/routing'; export default function sitemap(): MetadataRoute.Sitemap { return [getEntry('/'), getEntry('/pathnames')]; diff --git a/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx b/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx index 051a36f61..ca8b90fe3 100644 --- a/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx +++ b/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx @@ -2,8 +2,9 @@ import clsx from 'clsx'; import {useParams} from 'next/navigation'; +import {Locale} from 'next-intl'; import {ChangeEvent, ReactNode, useTransition} from 'react'; -import {Locale, usePathname, useRouter} from '@/i18n/routing'; +import {usePathname, useRouter} from '@/i18n/routing'; type Props = { children: ReactNode; diff --git a/examples/example-app-router/src/i18n/request.ts b/examples/example-app-router/src/i18n/request.ts index 2ba1dd2a6..370fc6d0c 100644 --- a/examples/example-app-router/src/i18n/request.ts +++ b/examples/example-app-router/src/i18n/request.ts @@ -1,14 +1,13 @@ +import {hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that the incoming `locale` is valid - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; return { locale, diff --git a/examples/example-app-router/src/i18n/routing.ts b/examples/example-app-router/src/i18n/routing.ts index 001b856ff..9ad5090ad 100644 --- a/examples/example-app-router/src/i18n/routing.ts +++ b/examples/example-app-router/src/i18n/routing.ts @@ -13,8 +13,5 @@ export const routing = defineRouting({ } }); -export type Pathnames = keyof typeof routing.pathnames; -export type Locale = (typeof routing.locales)[number]; - export const {Link, getPathname, redirect, usePathname, useRouter} = createNavigation(routing); diff --git a/examples/example-pages-router-advanced/global.d.ts b/examples/example-pages-router-advanced/global.d.ts index b749518b9..02f24a1b3 100644 --- a/examples/example-pages-router-advanced/global.d.ts +++ b/examples/example-pages-router-advanced/global.d.ts @@ -1,8 +1,7 @@ import en from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Messages: typeof en; + } } diff --git a/examples/example-pages-router-advanced/src/pages/_app.tsx b/examples/example-pages-router-advanced/src/pages/_app.tsx index 14f75d12d..165d5fcbb 100644 --- a/examples/example-pages-router-advanced/src/pages/_app.tsx +++ b/examples/example-pages-router-advanced/src/pages/_app.tsx @@ -1,9 +1,9 @@ import {AppProps} from 'next/app'; import {useRouter} from 'next/router'; -import {NextIntlClientProvider} from 'next-intl'; +import {Messages, NextIntlClientProvider} from 'next-intl'; type PageProps = { - messages: IntlMessages; + messages: Messages; now: number; }; diff --git a/examples/example-pages-router/global.d.ts b/examples/example-pages-router/global.d.ts index b749518b9..02f24a1b3 100644 --- a/examples/example-pages-router/global.d.ts +++ b/examples/example-pages-router/global.d.ts @@ -1,8 +1,7 @@ import en from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Messages: typeof en; + } } diff --git a/examples/example-use-intl/global.d.ts b/examples/example-use-intl/global.d.ts new file mode 100644 index 000000000..5f39e9b68 --- /dev/null +++ b/examples/example-use-intl/global.d.ts @@ -0,0 +1,10 @@ +import 'use-intl'; +import en from './messages/en.json'; +import {locales} from './src/config'; + +declare module 'use-intl' { + interface AppConfig { + Locale: (typeof locales)[number]; + Messages: typeof en; + } +} diff --git a/examples/example-use-intl/messages/en.json b/examples/example-use-intl/messages/en.json new file mode 100644 index 000000000..51f26812a --- /dev/null +++ b/examples/example-use-intl/messages/en.json @@ -0,0 +1,5 @@ +{ + "App": { + "hello": "Hello {username}!" + } +} diff --git a/examples/example-use-intl/src/config.tsx b/examples/example-use-intl/src/config.tsx new file mode 100644 index 000000000..a8a68c781 --- /dev/null +++ b/examples/example-use-intl/src/config.tsx @@ -0,0 +1 @@ +export const locales = ['en'] as const; diff --git a/examples/example-use-intl/src/main.tsx b/examples/example-use-intl/src/main.tsx index dc772d8ab..d48504350 100644 --- a/examples/example-use-intl/src/main.tsx +++ b/examples/example-use-intl/src/main.tsx @@ -1,16 +1,13 @@ import {StrictMode} from 'react'; import ReactDOM from 'react-dom/client'; import {IntlProvider} from 'use-intl'; +import en from '../messages/en.json'; import App from './App.tsx'; // You can get the messages from anywhere you like. You can also // fetch them from within a component and then render the provider // along with your app once you have the messages. -const messages = { - App: { - hello: 'Hello {username}!' - } -}; +const messages = en; const node = document.getElementById('root'); diff --git a/examples/example-use-intl/tsconfig.json b/examples/example-use-intl/tsconfig.json index ba939e9e6..309ae7f00 100644 --- a/examples/example-use-intl/tsconfig.json +++ b/examples/example-use-intl/tsconfig.json @@ -13,6 +13,6 @@ "noEmit": true, "jsx": "react-jsx" }, - "include": ["src"], + "include": ["src", "global.d.ts"], "references": [{"path": "./tsconfig.node.json"}] } diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 7070e0dc7..d48a2124b 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -4,7 +4,7 @@ const config: SizeLimitConfig = [ { name: "import * from 'next-intl' (react-client, production)", path: 'dist/esm/production/index.react-client.js', - limit: '13.11 KB' + limit: '13.175 KB' }, { name: "import {NextIntlClientProvider} from 'next-intl' (react-client, production)", diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 4b42ec8fb..1d3f26f96 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -59,7 +59,7 @@ export default function getAlternateLinksHeaderValue< routing.localePrefix ); - function getAlternateEntry(url: URL, locale: string) { + function getAlternateEntry(url: URL, locale: AppLocales[number]) { url.pathname = normalizeTrailingSlash(url.pathname); if (request.nextUrl.basePath) { diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index 3b6db2daf..2719ee2cf 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -1,6 +1,7 @@ import {match} from '@formatjs/intl-localematcher'; import Negotiator from 'negotiator'; import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies.js'; +import type {Locale} from 'use-intl'; import {ResolvedRoutingConfig} from '../routing/config.tsx'; import { DomainConfig, @@ -36,7 +37,7 @@ function orderLocales(locales: AppLocales) { export function getAcceptLanguageLocale( requestHeaders: Headers, locales: AppLocales, - defaultLocale: string + defaultLocale: Locale ) { let locale; @@ -47,12 +48,7 @@ export function getAcceptLanguageLocale( }).languages(); try { const orderedLocales = orderLocales(locales); - - locale = match( - languages, - orderedLocales as unknown as Array, - defaultLocale - ); + locale = match(languages, orderedLocales, defaultLocale); } catch { // Invalid language } diff --git a/packages/next-intl/src/middleware/syncCookie.tsx b/packages/next-intl/src/middleware/syncCookie.tsx index ffd7ad46f..751240c71 100644 --- a/packages/next-intl/src/middleware/syncCookie.tsx +++ b/packages/next-intl/src/middleware/syncCookie.tsx @@ -1,4 +1,5 @@ import {NextRequest, NextResponse} from 'next/server.js'; +import type {Locale} from 'use-intl'; import { InitializedLocaleCookieConfig, ResolvedRoutingConfig @@ -20,7 +21,7 @@ export default function syncCookie< >( request: NextRequest, response: NextResponse, - locale: string, + locale: Locale, routing: Pick< ResolvedRoutingConfig< AppLocales, diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 7f14655da..67013bd5b 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -1,3 +1,4 @@ +import type {Locale} from 'use-intl'; import { DomainConfig, DomainsConfig, @@ -254,7 +255,7 @@ export function getHost(requestHeaders: Headers) { } export function isLocaleSupportedOnDomain( - locale: string, + locale: Locale, domain: DomainConfig ) { return ( @@ -266,7 +267,7 @@ export function isLocaleSupportedOnDomain( export function getBestMatchingDomain( curHostDomain: DomainConfig | undefined, - locale: string, + locale: Locale, domainsConfig: DomainsConfig ) { let domainConfig; diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index e8125d65e..92f05556b 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -6,7 +6,9 @@ import { useParams as nextUseParams } from 'next/navigation.js'; import {renderToString} from 'react-dom/server'; +import {Locale} from 'use-intl'; import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {useLocale} from '../index.react-server.tsx'; import {DomainsConfig, Pathnames, defineRouting} from '../routing.tsx'; import createNavigationClient from './react-client/createNavigation.tsx'; import createNavigationServer from './react-server/createNavigation.tsx'; @@ -24,7 +26,7 @@ vi.mock('next/navigation.js', async () => { }); vi.mock('./react-server/getServerLocale'); -function mockCurrentLocale(locale: string) { +function mockCurrentLocale(locale: Locale) { // Enable synchronous rendering without having to suspend const value = locale; const promise = Promise.resolve(value); @@ -33,7 +35,7 @@ function mockCurrentLocale(locale: string) { vi.mocked(getServerLocale).mockImplementation(() => promise); - vi.mocked(nextUseParams<{locale: string}>).mockImplementation(() => ({ + vi.mocked(nextUseParams<{locale: Locale}>).mockImplementation(() => ({ locale })); } @@ -109,6 +111,19 @@ describe.each([ localePrefix: 'always' }); + describe('createNavigation', () => { + it('ensures `defaultLocale` is in `locales`', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => + createNavigation({ + locales, + // @ts-expect-error + defaultLocale: 'zh', + localePrefix: 'always' + }); + }); + }); + describe('Link', () => { it('renders a prefix when currently on the default locale', () => { const markup = renderToString(About); @@ -214,6 +229,14 @@ describe.each([ expect(markup).toContain('href="/en/about"'); expect(consoleSpy).not.toHaveBeenCalled(); }); + + it('can use a locale from `useLocale`', () => { + function Component() { + const locale = useLocale(); + return ; + } + render(); + }); }); describe('getPathname', () => { @@ -305,6 +328,17 @@ describe.each([ true ); }); + + it('can use a locale from `useLocale`', () => { + function Component() { + const locale = useLocale(); + return getPathname({ + locale, + href: '/about' + }); + } + render(); + }); }); describe.each([ @@ -353,6 +387,14 @@ describe.each([ // @ts-expect-error -- Missing locale redirectFn({pathname: '/about'}); }); + + it('can use a locale from `useLocale`', () => { + function Component() { + const locale = useLocale(); + return redirectFn({href: '/about', locale}); + } + render(); + }); }); }); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index 551c7b3fc..ca9b0968b 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -4,15 +4,16 @@ import { useRouter as useNextRouter, useParams } from 'next/navigation.js'; +import type {Locale} from 'use-intl'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {NextIntlClientProvider} from '../../index.react-client.tsx'; +import {NextIntlClientProvider, useLocale} from '../../index.react-client.tsx'; import {DomainsConfig, Pathnames} from '../../routing.tsx'; import createNavigation from './createNavigation.tsx'; vi.mock('next/navigation.js'); -function mockCurrentLocale(locale: string) { - vi.mocked(useParams<{locale: string}>).mockImplementation(() => ({ +function mockCurrentLocale(locale: Locale) { + vi.mocked(useParams<{locale: Locale}>).mockImplementation(() => ({ locale })); } @@ -169,16 +170,20 @@ describe("localePrefix: 'always'", () => { }); it('prefixes with a secondary locale', () => { - // Being able to accept a string and not only a strictly typed locale is - // important in order to be able to use a result from `useLocale()`. - // This is less relevant for `Link`, but this should be in sync across - // al navigation APIs (see https://github.com/amannn/next-intl/issues/1377) - const locale = 'de' as string; - - invokeRouter((router) => router[method]('/about', {locale})); + invokeRouter((router) => router[method]('/about', {locale: 'de'})); expect(useNextRouter()[method]).toHaveBeenCalledWith('/de/about'); }); + it('can use a locale from `useLocale`', () => { + function Component() { + const locale = useLocale(); + const router = useRouter(); + router.push('/about', {locale}); + return null; + } + render(); + }); + it('passes through unknown options to the Next.js router', () => { invokeRouter((router) => router[method]('/about', {scroll: true})); expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about', { diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index d9b4a451a..0cdcc4a56 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -3,6 +3,7 @@ import { useRouter as useNextRouter } from 'next/navigation.js'; import {useMemo} from 'react'; +import type {Locale} from 'use-intl'; import useLocale from '../../react-client/useLocale.tsx'; import { RoutingConfigLocalizedNavigation, @@ -40,14 +41,8 @@ export default function createNavigation< AppDomains > ) { - type Locale = AppLocales extends never ? string : AppLocales[number]; - - function useTypedLocale() { - return useLocale() as Locale; - } - const {Link, config, getPathname, ...redirects} = createSharedNavigationFns( - useTypedLocale, + useLocale, routing ); @@ -56,7 +51,7 @@ export default function createNavigation< ? string : keyof AppPathnames { const pathname = useBasePathname(config.localePrefix); - const locale = useTypedLocale(); + const locale = useLocale(); // @ts-expect-error -- Mirror the behavior from Next.js, where `null` is returned when `usePathname` is used outside of Next, but the types indicate that a string is always returned. return useMemo( @@ -77,7 +72,7 @@ export default function createNavigation< function useRouter() { const router = useNextRouter(); - const curLocale = useTypedLocale(); + const curLocale = useLocale(); const nextPathname = useNextPathname(); return useMemo(() => { @@ -87,7 +82,7 @@ export default function createNavigation< >(fn: Fn) { return function handler( href: Parameters[0]['href'], - options?: Partial & {locale?: string} + options?: Partial & {locale?: Locale} ): void { const {locale: nextLocale, ...rest} = options || {}; diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index c17732737..90d7f8721 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -32,14 +32,8 @@ export default function createNavigation< AppDomains > ) { - type Locale = AppLocales extends never ? string : AppLocales[number]; - - function getLocale() { - return getServerLocale() as Promise; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const {config, ...fns} = createSharedNavigationFns(getLocale, routing); + const {config, ...fns} = createSharedNavigationFns(getServerLocale, routing); function notSupported(hookName: string) { return () => { diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index d5a63931b..2ae09aa87 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'; +import type {Locale} from 'use-intl'; import useLocale from '../../react-client/useLocale.tsx'; import {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; import syncLocaleCookie from './syncLocaleCookie.tsx'; @@ -18,12 +19,12 @@ type NextLinkProps = Omit, keyof LinkProps> & Omit; type Props = NextLinkProps & { - locale?: string; - defaultLocale?: string; + locale?: Locale; + defaultLocale?: Locale; localeCookie: InitializedLocaleCookieConfig; /** Special case for `localePrefix: 'as-needed'` and `domains`. */ unprefixed?: { - domains: {[domain: string]: string}; + domains: {[domain: string]: Locale}; pathname: string; }; }; diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index 131e0270d..c2fe5a5fa 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -3,6 +3,7 @@ import { redirect as nextRedirect } from 'next/navigation.js'; import {ComponentProps, forwardRef} from 'react'; +import type {Locale} from 'use-intl'; import { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation, @@ -31,8 +32,6 @@ import { } from './utils.tsx'; type PromiseOrValue = Type | Promise; -type UnwrapPromiseOrValue = - Type extends Promise ? Value : Type; /** * Shared implementations for `react-server` and `react-client` @@ -43,9 +42,7 @@ export default function createSharedNavigationFns< const AppLocalePrefixMode extends LocalePrefixMode = 'always', const AppDomains extends DomainsConfig = never >( - getLocale: () => PromiseOrValue< - AppLocales extends never ? string : AppLocales[number] - >, + getLocale: () => PromiseOrValue, routing?: [AppPathnames] extends [never] ? | RoutingConfigSharedNavigation< @@ -61,8 +58,6 @@ export default function createSharedNavigationFns< AppDomains > ) { - type Locale = UnwrapPromiseOrValue>; - const config = receiveRoutingConfig(routing || {}); if (process.env.NODE_ENV !== 'production') { validateReceivedConfig(config); @@ -92,7 +87,7 @@ export default function createSharedNavigationFns< ? ComponentProps['href'] : HrefOrUrlObjectWithParams; /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#link */ - locale?: string; + locale?: Locale; } >; function Link( @@ -112,7 +107,7 @@ export default function createSharedNavigationFns< const isLocalizable = isLocalizableHref(href); const localePromiseOrValue = getLocale(); - const curLocale: AppLocales extends never ? string : AppLocales[number] = + const curLocale = localePromiseOrValue instanceof Promise ? use(localePromiseOrValue) : localePromiseOrValue; @@ -148,10 +143,9 @@ export default function createSharedNavigationFns< ? { domains: (config as any).domains.reduce( ( - acc: Record, + acc: Record, domain: DomainConfig ) => { - // @ts-expect-error -- This is ok acc[domain.domain] = domain.defaultLocale; return acc; }, @@ -194,7 +188,7 @@ export default function createSharedNavigationFns< href: [AppPathnames] extends [never] ? string | {pathname: string; query?: QueryParams} : HrefOrHrefWithParams; - locale: string; + locale: Locale; } & DomainConfigForAsNeeded, /** @private Removed in types returned below */ _forcePrefix?: boolean diff --git a/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx b/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx index eabd9dc23..5b2d95722 100644 --- a/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx +++ b/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx @@ -1,3 +1,4 @@ +import type {Locale} from 'use-intl'; import {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; import {getBasePath} from './utils.tsx'; @@ -9,8 +10,8 @@ import {getBasePath} from './utils.tsx'; export default function syncLocaleCookie( localeCookie: InitializedLocaleCookieConfig, pathname: string | null, - locale: string, - nextLocale?: string + locale: Locale, + nextLocale?: Locale ) { const isSwitchingLocale = nextLocale !== locale && nextLocale != null; diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 31c44def7..3845920be 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,5 +1,6 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; +import type {Locale} from 'use-intl'; import {ResolvedRoutingConfig} from '../../routing/config.tsx'; import { DomainsConfig, @@ -49,7 +50,7 @@ export function normalizeNameOrNameWithParams( href: | HrefOrHrefWithParams | { - locale: string; + locale: Locale; href: HrefOrHrefWithParams; } ): { diff --git a/packages/next-intl/src/react-client/useLocale.tsx b/packages/next-intl/src/react-client/useLocale.tsx index 227342528..7274813d9 100644 --- a/packages/next-intl/src/react-client/useLocale.tsx +++ b/packages/next-intl/src/react-client/useLocale.tsx @@ -2,7 +2,7 @@ import {useLocale as useBaseLocale} from 'use-intl/react'; import {LOCALE_SEGMENT_NAME} from '../shared/constants.tsx'; import useParams from '../shared/useParams.tsx'; -export default function useLocale(): string { +export default function useLocale(): ReturnType { const params = useParams(); let locale; diff --git a/packages/next-intl/src/react-server/getTranslator.tsx b/packages/next-intl/src/react-server/getTranslator.tsx index 640170d09..7e462d8ce 100644 --- a/packages/next-intl/src/react-server/getTranslator.tsx +++ b/packages/next-intl/src/react-server/getTranslator.tsx @@ -3,6 +3,7 @@ import { Formats, MarkupTranslationValues, MessageKeys, + Messages, NamespaceKeys, NestedKeyOf, NestedValueOf, @@ -12,10 +13,7 @@ import { } from 'use-intl/core'; function getTranslatorImpl< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never + NestedKey extends NamespaceKeys> = never >( config: Parameters[0], namespace?: NestedKey @@ -25,12 +23,12 @@ function getTranslatorImpl< < TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -45,12 +43,12 @@ function getTranslatorImpl< rich< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -65,12 +63,12 @@ function getTranslatorImpl< markup< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -85,12 +83,12 @@ function getTranslatorImpl< raw< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -103,12 +101,12 @@ function getTranslatorImpl< has< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > diff --git a/packages/next-intl/src/routing/types.tsx b/packages/next-intl/src/routing/types.tsx index d47c5ee2e..48929e150 100644 --- a/packages/next-intl/src/routing/types.tsx +++ b/packages/next-intl/src/routing/types.tsx @@ -1,3 +1,5 @@ +// We intentionally don't use `Locale` here to avoid a circular reference +// when `routing` is used to initialize the `Locale` type. export type Locales = ReadonlyArray; export type LocalePrefixMode = 'always' | 'as-needed' | 'never'; diff --git a/packages/next-intl/src/server/react-server/RequestLocale.tsx b/packages/next-intl/src/server/react-server/RequestLocale.tsx index 91ea93cb5..12b18c627 100644 --- a/packages/next-intl/src/server/react-server/RequestLocale.tsx +++ b/packages/next-intl/src/server/react-server/RequestLocale.tsx @@ -1,5 +1,6 @@ import {headers} from 'next/headers.js'; import {cache} from 'react'; +import {Locale} from 'use-intl'; import {HEADER_LOCALE_NAME} from '../../shared/constants.tsx'; import {getCachedRequestLocale} from './RequestLocaleCache.tsx'; @@ -13,7 +14,7 @@ async function getHeadersImpl(): Promise { } const getHeaders = cache(getHeadersImpl); -async function getLocaleFromHeaderImpl(): Promise { +async function getLocaleFromHeaderImpl(): Promise { let locale; try { diff --git a/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx b/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx index a8bc80194..98219f2b2 100644 --- a/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx +++ b/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx @@ -1,8 +1,9 @@ import {cache} from 'react'; +import type {Locale} from 'use-intl'; // See https://github.com/vercel/next.js/discussions/58862 function getCacheImpl() { - const value: {locale?: string} = {locale: undefined}; + const value: {locale?: Locale} = {locale: undefined}; return value; } @@ -12,6 +13,6 @@ export function getCachedRequestLocale() { return getCache().locale; } -export function setCachedRequestLocale(locale: string) { +export function setCachedRequestLocale(locale: Locale) { getCache().locale = locale; } diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index 0661e1de0..a5c2a85bb 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -1,6 +1,7 @@ import {cache} from 'react'; import { IntlConfig, + type Locale, _createCache, _createIntlFormatters, initializeConfig @@ -24,7 +25,7 @@ const getDefaultTimeZone = cache(getDefaultTimeZoneImpl); async function receiveRuntimeConfigImpl( getConfig: typeof createRequestConfig, - localeOverride?: string + localeOverride?: Locale ) { if ( process.env.NODE_ENV !== 'production' && @@ -76,7 +77,7 @@ const receiveRuntimeConfig = cache(receiveRuntimeConfigImpl); const getFormatters = cache(_createIntlFormatters); const getCache = cache(_createCache); -async function getConfigImpl(localeOverride?: string): Promise< +async function getConfigImpl(localeOverride?: Locale): Promise< IntlConfig & { getMessageFallback: NonNullable; now: NonNullable; diff --git a/packages/next-intl/src/server/react-server/getFormatter.tsx b/packages/next-intl/src/server/react-server/getFormatter.tsx index 830eb4595..9e1909076 100644 --- a/packages/next-intl/src/server/react-server/getFormatter.tsx +++ b/packages/next-intl/src/server/react-server/getFormatter.tsx @@ -1,8 +1,8 @@ import {cache} from 'react'; -import {createFormatter} from 'use-intl/core'; +import {type Locale, createFormatter} from 'use-intl/core'; import getConfig from './getConfig.tsx'; -async function getFormatterCachedImpl(locale?: string) { +async function getFormatterCachedImpl(locale?: Locale) { const config = await getConfig(locale); return createFormatter(config); } @@ -15,7 +15,7 @@ const getFormatterCached = cache(getFormatterCachedImpl); * you can override it by passing in additional options. */ export default async function getFormatter(opts?: { - locale?: string; + locale?: Locale; }): Promise> { return getFormatterCached(opts?.locale); } diff --git a/packages/next-intl/src/server/react-server/getLocale.tsx b/packages/next-intl/src/server/react-server/getLocale.tsx index e847b30cf..caea190f8 100644 --- a/packages/next-intl/src/server/react-server/getLocale.tsx +++ b/packages/next-intl/src/server/react-server/getLocale.tsx @@ -1,7 +1,8 @@ import {cache} from 'react'; +import {Locale} from 'use-intl'; import getConfig from './getConfig.tsx'; -async function getLocaleCachedImpl() { +async function getLocaleCachedImpl(): Promise { const config = await getConfig(); return config.locale; } diff --git a/packages/next-intl/src/server/react-server/getMessages.tsx b/packages/next-intl/src/server/react-server/getMessages.tsx index 39ac4ca0d..95b521fde 100644 --- a/packages/next-intl/src/server/react-server/getMessages.tsx +++ b/packages/next-intl/src/server/react-server/getMessages.tsx @@ -1,5 +1,5 @@ import {cache} from 'react'; -import type {useMessages as useMessagesType} from 'use-intl'; +import type {Locale, useMessages as useMessagesType} from 'use-intl'; import getConfig from './getConfig.tsx'; export function getMessagesFromConfig( @@ -13,12 +13,14 @@ export function getMessagesFromConfig( return config.messages; } -async function getMessagesCachedImpl(locale?: string) { +async function getMessagesCachedImpl(locale?: Locale) { const config = await getConfig(locale); return getMessagesFromConfig(config); } const getMessagesCached = cache(getMessagesCachedImpl); -export default async function getMessages(opts?: {locale?: string}) { +export default async function getMessages(opts?: { + locale?: Locale; +}): Promise> { return getMessagesCached(opts?.locale); } diff --git a/packages/next-intl/src/server/react-server/getNow.tsx b/packages/next-intl/src/server/react-server/getNow.tsx index d081c14f0..ed39c17f9 100644 --- a/packages/next-intl/src/server/react-server/getNow.tsx +++ b/packages/next-intl/src/server/react-server/getNow.tsx @@ -1,12 +1,13 @@ import {cache} from 'react'; +import type {Locale} from 'use-intl'; import getConfig from './getConfig.tsx'; -async function getNowCachedImpl(locale?: string) { +async function getNowCachedImpl(locale?: Locale) { const config = await getConfig(locale); return config.now; } const getNowCached = cache(getNowCachedImpl); -export default async function getNow(opts?: {locale?: string}): Promise { +export default async function getNow(opts?: {locale?: Locale}): Promise { return getNowCached(opts?.locale); } diff --git a/packages/next-intl/src/server/react-server/getTimeZone.tsx b/packages/next-intl/src/server/react-server/getTimeZone.tsx index d6a707f0e..fca1601f9 100644 --- a/packages/next-intl/src/server/react-server/getTimeZone.tsx +++ b/packages/next-intl/src/server/react-server/getTimeZone.tsx @@ -1,14 +1,15 @@ import {cache} from 'react'; +import type {Locale} from 'use-intl'; import getConfig from './getConfig.tsx'; -async function getTimeZoneCachedImpl(locale?: string) { +async function getTimeZoneCachedImpl(locale?: Locale) { const config = await getConfig(locale); return config.timeZone; } const getTimeZoneCached = cache(getTimeZoneCachedImpl); export default async function getTimeZone(opts?: { - locale?: string; -}): Promise { + locale?: Locale; +}): Promise { return getTimeZoneCached(opts?.locale); } diff --git a/packages/next-intl/src/server/react-server/getTranslations.tsx b/packages/next-intl/src/server/react-server/getTranslations.tsx index 3119c3ddf..ce1129e97 100644 --- a/packages/next-intl/src/server/react-server/getTranslations.tsx +++ b/packages/next-intl/src/server/react-server/getTranslations.tsx @@ -1,8 +1,10 @@ import {ReactNode, cache} from 'react'; import { Formats, + Locale, MarkupTranslationValues, MessageKeys, + Messages, NamespaceKeys, NestedKeyOf, NestedValueOf, @@ -18,10 +20,7 @@ import getConfig from './getConfig.tsx'; // CALL SIGNATURE 1: `getTranslations(namespace)` function getTranslations< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never + NestedKey extends NamespaceKeys> = never >( namespace?: NestedKey ): // Explicitly defining the return type is necessary as TypeScript would get it wrong @@ -30,12 +29,12 @@ Promise<{ < TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -50,12 +49,12 @@ Promise<{ rich< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -70,12 +69,12 @@ Promise<{ markup< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -90,12 +89,12 @@ Promise<{ raw< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -108,12 +107,12 @@ Promise<{ has< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -124,12 +123,9 @@ Promise<{ }>; // CALL SIGNATURE 2: `getTranslations({locale, namespace})` function getTranslations< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never + NestedKey extends NamespaceKeys> = never >(opts?: { - locale: string; + locale: Locale; namespace?: NestedKey; }): // Explicitly defining the return type is necessary as TypeScript would get it wrong Promise<{ @@ -137,12 +133,12 @@ Promise<{ < TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -157,12 +153,12 @@ Promise<{ rich< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -177,12 +173,12 @@ Promise<{ markup< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -197,12 +193,12 @@ Promise<{ raw< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -213,13 +209,10 @@ Promise<{ }>; // IMPLEMENTATION async function getTranslations< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never ->(namespaceOrOpts?: NestedKey | {locale: string; namespace?: NestedKey}) { + NestedKey extends NamespaceKeys> = never +>(namespaceOrOpts?: NestedKey | {locale: Locale; namespace?: NestedKey}) { let namespace: NestedKey | undefined; - let locale: string | undefined; + let locale: Locale | undefined; if (typeof namespaceOrOpts === 'string') { namespace = namespaceOrOpts; diff --git a/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx b/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx index 2d3cc8cd8..7cf3b13c8 100644 --- a/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx +++ b/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx @@ -1,4 +1,5 @@ import {render, screen} from '@testing-library/react'; +import type {Locale} from 'use-intl'; import {it, vi} from 'vitest'; import { NextIntlClientProvider, @@ -16,7 +17,7 @@ function Component() { return <>{t('message', {price: 29000.5})}; } -function TestProvider({locale}: {locale?: string}) { +function TestProvider({locale}: {locale?: Locale}) { return ( , 'locale'> & { /** This is automatically received when being rendered from a Server Component. In all other cases, e.g. when rendered from a Client Component, a unit test or with the Pages Router, you can pass this prop explicitly. */ - locale?: string; + locale?: Locale; }; export default function NextIntlClientProvider({locale, ...rest}: Props) { diff --git a/packages/next-intl/types/index.d.ts b/packages/next-intl/types/index.d.ts index 7fc5571da..a6c395566 100644 --- a/packages/next-intl/types/index.d.ts +++ b/packages/next-intl/types/index.d.ts @@ -4,8 +4,6 @@ declare namespace NodeJS { } } -declare interface IntlMessages extends Record {} - // Temporarly copied here until the "es2020.intl" lib is published. declare namespace Intl { /** diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index a21e2f62f..564bc6eaf 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -5,7 +5,7 @@ const config: SizeLimitConfig = [ name: "import * from 'use-intl' (production)", import: '*', path: 'dist/esm/production/index.js', - limit: '12.965 kB' + limit: '12.985 kB' }, { name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (production)", @@ -13,12 +13,6 @@ const config: SizeLimitConfig = [ import: '{IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter}', limit: '1.975 kB' - }, - { - name: "import * from 'use-intl' (development)", - import: '*', - path: 'dist/esm/development/index.js', - limit: '13.905 kB' } ]; diff --git a/packages/use-intl/src/core/AbstractIntlMessages.tsx b/packages/use-intl/src/core/AbstractIntlMessages.tsx index 3a62dec89..93dae895e 100644 --- a/packages/use-intl/src/core/AbstractIntlMessages.tsx +++ b/packages/use-intl/src/core/AbstractIntlMessages.tsx @@ -1,7 +1,7 @@ /** * A generic type that describes the shape of messages. * - * Optionally, `IntlMessages` can be provided to get type safety for message + * Optionally, messages can be strictly-typed in order to get type safety for message * namespaces and keys. See https://next-intl-docs.vercel.app/docs/usage/typescript */ type AbstractIntlMessages = { diff --git a/packages/use-intl/src/core/AppConfig.tsx b/packages/use-intl/src/core/AppConfig.tsx new file mode 100644 index 000000000..1bd0dec03 --- /dev/null +++ b/packages/use-intl/src/core/AppConfig.tsx @@ -0,0 +1,37 @@ +export default interface AppConfig { + // Locale + // Formats + // Messages +} + +export type Locale = AppConfig extends { + Locale: infer AppLocale; +} + ? AppLocale + : string; + +export type FormatNames = AppConfig extends { + Formats: infer AppFormats; +} + ? { + dateTime: AppFormats extends {dateTime: infer AppDateTimeFormats} + ? keyof AppDateTimeFormats + : string; + number: AppFormats extends {number: infer AppNumberFormats} + ? keyof AppNumberFormats + : string; + list: AppFormats extends {list: infer AppListFormats} + ? keyof AppListFormats + : string; + } + : { + dateTime: string; + number: string; + list: string; + }; + +export type Messages = AppConfig extends { + Messages: infer AppMessages; +} + ? AppMessages + : Record; diff --git a/packages/use-intl/src/core/IntlConfig.tsx b/packages/use-intl/src/core/IntlConfig.tsx index d6eaa2528..e5b379a9d 100644 --- a/packages/use-intl/src/core/IntlConfig.tsx +++ b/packages/use-intl/src/core/IntlConfig.tsx @@ -1,4 +1,5 @@ import type AbstractIntlMessages from './AbstractIntlMessages.tsx'; +import type {Locale} from './AppConfig.tsx'; import type Formats from './Formats.tsx'; import type IntlError from './IntlError.tsx'; import type TimeZone from './TimeZone.tsx'; @@ -9,7 +10,7 @@ import type TimeZone from './TimeZone.tsx'; type IntlConfig = { /** A valid Unicode locale tag (e.g. "en" or "en-GB"). */ - locale: string; + locale: Locale; /** Global formats can be provided to achieve consistent * formatting across components. */ formats?: Formats; diff --git a/packages/use-intl/src/core/createBaseTranslator.tsx b/packages/use-intl/src/core/createBaseTranslator.tsx index 28de377e8..2806023be 100644 --- a/packages/use-intl/src/core/createBaseTranslator.tsx +++ b/packages/use-intl/src/core/createBaseTranslator.tsx @@ -1,6 +1,7 @@ import {IntlMessageFormat} from 'intl-messageformat'; import {ReactNode, cloneElement, isValidElement} from 'react'; import AbstractIntlMessages from './AbstractIntlMessages.tsx'; +import {Locale} from './AppConfig.tsx'; import Formats from './Formats.tsx'; import {InitializedIntlConfig} from './IntlConfig.tsx'; import IntlError, {IntlErrorCode} from './IntlError.tsx'; @@ -41,7 +42,7 @@ function createMessageFormatter( } function resolvePath( - locale: string, + locale: Locale, messages: AbstractIntlMessages | undefined, key: string, namespace?: string @@ -103,7 +104,7 @@ function prepareTranslationValues(values: RichTranslationValues) { } function getMessagesOrError( - locale: string, + locale: Locale, messages?: Messages, namespace?: string, onError: (error: IntlError) => void = defaultOnError diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index 8549f315d..02c38cf5a 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -1,4 +1,5 @@ import {ReactElement} from 'react'; +import {FormatNames, Locale} from './AppConfig.tsx'; import DateTimeFormatOptions from './DateTimeFormatOptions.tsx'; import Formats from './Formats.tsx'; import IntlError, {IntlErrorCode} from './IntlError.tsx'; @@ -70,7 +71,7 @@ function calculateRelativeTimeValue( } type Props = { - locale: string; + locale: Locale; timeZone?: TimeZone; onError?(error: IntlError): void; formats?: Formats; @@ -163,9 +164,7 @@ export default function createFormatter({ value: Date | number, /** If a time zone is supplied, the `value` is converted to that time zone. * Otherwise the user time zone will be used. */ - formatOrOptions?: - | Extract - | DateTimeFormatOptions + formatOrOptions?: FormatNames['dateTime'] | DateTimeFormatOptions ) { return getFormattedValue( formatOrOptions, @@ -185,9 +184,7 @@ export default function createFormatter({ end: Date | number, /** If a time zone is supplied, the values are converted to that time zone. * Otherwise the user time zone will be used. */ - formatOrOptions?: - | Extract - | DateTimeFormatOptions + formatOrOptions?: FormatNames['dateTime'] | DateTimeFormatOptions ) { return getFormattedValue( formatOrOptions, @@ -204,9 +201,7 @@ export default function createFormatter({ function number( value: number | bigint, - formatOrOptions?: - | Extract - | NumberFormatOptions + formatOrOptions?: FormatNames['number'] | NumberFormatOptions ) { return getFormattedValue( formatOrOptions, @@ -290,9 +285,7 @@ export default function createFormatter({ type FormattableListValue = string | ReactElement; function list( value: Iterable, - formatOrOptions?: - | Extract - | Intl.ListFormatOptions + formatOrOptions?: FormatNames['list'] | Intl.ListFormatOptions ): Value extends string ? string : Iterable { const serializedValue: Array = []; const richValues = new Map(); diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index 6bc825486..cf51904fb 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -1,4 +1,5 @@ import {ReactNode} from 'react'; +import {Messages} from './AppConfig.tsx'; import Formats from './Formats.tsx'; import IntlConfig from './IntlConfig.tsx'; import TranslationValues, { @@ -27,10 +28,7 @@ import NestedValueOf from './utils/NestedValueOf.tsx'; * (e.g. `namespace.Component`). */ export default function createTranslator< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never + NestedKey extends NamespaceKeys> = never >({ _cache = createCache(), _formatters = createIntlFormatters(_cache), @@ -39,8 +37,8 @@ export default function createTranslator< namespace, onError = defaultOnError, ...rest -}: Omit, 'messages'> & { - messages?: IntlConfig['messages']; +}: Omit, 'messages'> & { + messages?: IntlConfig['messages']; namespace?: NestedKey; /** @private */ _formatters?: Formatters; @@ -52,12 +50,12 @@ export default function createTranslator< < TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -72,12 +70,12 @@ export default function createTranslator< rich< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -92,12 +90,12 @@ export default function createTranslator< markup< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -112,12 +110,12 @@ export default function createTranslator< raw< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -130,12 +128,12 @@ export default function createTranslator< has< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -148,7 +146,7 @@ export default function createTranslator< // namespace works correctly. See https://stackoverflow.com/a/71529575/343045 // The prefix ("!") is arbitrary. return createTranslatorImpl< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >( { diff --git a/packages/use-intl/src/core/hasLocale.test.tsx b/packages/use-intl/src/core/hasLocale.test.tsx new file mode 100644 index 000000000..19a6dfdcb --- /dev/null +++ b/packages/use-intl/src/core/hasLocale.test.tsx @@ -0,0 +1,95 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import hasLocale from './hasLocale.tsx'; + +it('narrows down the type', () => { + const locales = ['en-US', 'en-GB'] as const; + const candidate = 'en-US' as string; + if (hasLocale(locales, candidate)) { + candidate satisfies (typeof locales)[number]; + } +}); + +it('can be called with a matching narrow candidate', () => { + const locales = ['en-US', 'en-GB'] as const; + const candidate = 'en-US' as const; + if (hasLocale(locales, candidate)) { + candidate satisfies (typeof locales)[number]; + } +}); + +it('can be called with a non-matching narrow candidate', () => { + const locales = ['en-US', 'en-GB'] as const; + const candidate = 'de' as const; + if (hasLocale(locales, candidate)) { + candidate satisfies never; + } +}); + +describe('accepts valid formats', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it.each([ + 'en', + 'en-US', + 'EN-US', + 'en-us', + 'en-GB', + 'zh-Hans-CN', + 'es-419', + 'en-Latn', + 'zh-Hans', + 'en-US-u-ca-buddhist', + 'en-x-private1', + 'en-US-u-nu-thai', + 'ar-u-nu-arab', + 'en-t-m0-true', + 'zh-Hans-CN-x-private1-private2', + 'en-US-u-ca-gregory-nu-latn', + 'en-US-x-usd', + + // Somehow tolerated by Intl.Locale + 'english' + ])('accepts: %s', (locale) => { + expect(hasLocale([locale] as const, locale)).toBe(true); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); + +describe('warns for invalid formats', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it.each([ + 'en_US', + 'en-', + 'e-US', + 'en-USA', + 'und', + '123', + '-en', + 'en--US', + 'toolongstring', + 'en-US-', + '@#$', + 'en US', + 'en.US' + ])('rejects: %s', (locale) => { + hasLocale([locale] as const, locale); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/use-intl/src/core/hasLocale.tsx b/packages/use-intl/src/core/hasLocale.tsx new file mode 100644 index 000000000..6094cfd01 --- /dev/null +++ b/packages/use-intl/src/core/hasLocale.tsx @@ -0,0 +1,31 @@ +import type {Locale} from './AppConfig.tsx'; + +/** + * Checks if a locale exists in a list of locales. + * + * Additionally, in development, the provided locales are validated to + * ensure they follow the Unicode language identifier standard. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale + */ +export default function hasLocale( + locales: ReadonlyArray, + candidate?: string | null +): candidate is LocaleType { + if (process.env.NODE_ENV !== 'production') { + for (const locale of locales) { + try { + const constructed = new Intl.Locale(locale); + if (!constructed.language) { + throw new Error('Language is required'); + } + } catch { + console.error( + `Found invalid locale within provided \`locales\`: "${locale}"\nPlease ensure you're using a valid Unicode locale identifier (e.g. "en-US").` + ); + } + } + } + + return locales.includes(candidate as LocaleType); +} diff --git a/packages/use-intl/src/core/index.tsx b/packages/use-intl/src/core/index.tsx index 51a40f8a4..55459b66e 100644 --- a/packages/use-intl/src/core/index.tsx +++ b/packages/use-intl/src/core/index.tsx @@ -18,3 +18,5 @@ export type {default as NestedKeyOf} from './utils/NestedKeyOf.tsx'; export type {default as NestedValueOf} from './utils/NestedValueOf.tsx'; export {createIntlFormatters as _createIntlFormatters} from './formatters.tsx'; export {createCache as _createCache} from './formatters.tsx'; +export type {default as AppConfig, Locale, Messages} from './AppConfig.tsx'; +export {default as hasLocale} from './hasLocale.tsx'; diff --git a/packages/use-intl/src/react/index.test.tsx b/packages/use-intl/src/react/index.test.tsx index f66f596e0..d8f9de37e 100644 --- a/packages/use-intl/src/react/index.test.tsx +++ b/packages/use-intl/src/react/index.test.tsx @@ -1,6 +1,7 @@ import {render, screen} from '@testing-library/react'; import {parseISO} from 'date-fns'; import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {Locale} from '../core.tsx'; import IntlProvider from './IntlProvider.tsx'; import useFormatter from './useFormatter.tsx'; import useNow from './useNow.tsx'; @@ -24,7 +25,7 @@ describe('performance', () => { ); } - function App({locale}: {locale: string}) { + function App({locale}: {locale: Locale}) { return ( - > = never + NestedKey extends NamespaceKeys> = never >( namespace?: NestedKey ): // Explicitly defining the return type is necessary as TypeScript would get it wrong @@ -32,12 +30,12 @@ export default function useTranslations< < TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -52,12 +50,12 @@ export default function useTranslations< rich< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -72,12 +70,12 @@ export default function useTranslations< markup< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -92,12 +90,12 @@ export default function useTranslations< raw< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -110,12 +108,12 @@ export default function useTranslations< has< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -125,13 +123,13 @@ export default function useTranslations< ): boolean; } { const context = useIntlContext(); - const messages = context.messages as IntlMessages; + const messages = context.messages as Messages; // We have to wrap the actual hook so the type inference for the optional // namespace works correctly. See https://stackoverflow.com/a/71529575/343045 // The prefix ("!") is arbitrary. return useTranslationsImpl< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >( {'!': messages}, diff --git a/packages/use-intl/types/index.d.ts b/packages/use-intl/types/index.d.ts index 53e36206c..a6c395566 100644 --- a/packages/use-intl/types/index.d.ts +++ b/packages/use-intl/types/index.d.ts @@ -4,20 +4,7 @@ declare namespace NodeJS { } } -// This type is intended to be overridden -// by the consumer for optional type safety of messages -declare interface IntlMessages extends Record {} - -// This type is intended to be overridden -// by the consumer for optional type safety of formats -declare interface IntlFormats { - dateTime: any; - number: any; - list: any; -} - // Temporarly copied here until the "es2020.intl" lib is published. - declare namespace Intl { /** * [BCP 47 language tag](http://tools.ietf.org/html/rfc5646) definition.