diff --git a/docs/pages/docs/design-principles.mdx b/docs/pages/docs/design-principles.mdx index c7b0c50c3..af5ba8ab8 100644 --- a/docs/pages/docs/design-principles.mdx +++ b/docs/pages/docs/design-principles.mdx @@ -54,7 +54,7 @@ For text formatting, `next-intl` is based on [International Components for Unico By being based on standards, `next-intl` ensures that your internationalization code is future-proof and feels familiar to developers who have existing experience with internationalization. Additionally, relying on standards ensures that `next-intl` integrates well with translation management systems like Crowdin. -`next-intl` uses a [nested style](/docs/usage/messages#structuring-messages) to provide structure to messages, allowing to express hierarchies of messages without redundancy. By supporting only a single style, we can offer advanced features that rely on these assumptions like [type-safety for messages](/docs/workflows/typescript). If you're coming from a different style, you can consider migrating to the nested style (see "Can I use a different style for structuring my messages?" in [the structuring messages docs](/docs/usage/messages#structuring-messages)). +`next-intl` uses a [nested style](/docs/usage/messages#structuring-messages) to provide structure to messages, allowing to express hierarchies of messages without redundancy. By supporting only a single style, we can offer advanced features that rely on these assumptions like [type-safety for messages](/docs/workflows/typescript#messages). If you're coming from a different style, you can consider migrating to the nested style (see "Can I use a different style for structuring my messages?" in [the structuring messages docs](/docs/usage/messages#structuring-messages)). As standards can change, `next-intl` is expected to keep up with the latest developments in the ECMAScript standard (e.g. [`Temporal`](https://tc39.es/proposal-temporal/docs/) and [`Intl.MessageFormat`](https://github.com/tc39/proposal-intl-messageformat)). @@ -70,7 +70,7 @@ Typical apps require some of the following integrations: These are typically used to manage translations and to [collaborate with translators](/docs/workflows/localization-management). Services like Crowdin provide a wide range of features, allowing translators to work in a web-based interface on translations, while providing different mechanisms to sync translations with your app. -`next-intl` integrates well with these services as it uses ICU message syntax for defining text labels, which is a widely supported standard. The recommended way to store messages is in JSON files that are structured by locale since this is a popular format that can be imported into a TMS. While it's recommended to have at least the messages for the default locale available locally (e.g. for [type-safe messages](/docs/workflows/typescript)), you can also load messages dynamically, e.g. from a CDN that your TMS provides. +`next-intl` integrates well with these services as it uses ICU message syntax for defining text labels, which is a widely supported standard. The recommended way to store messages is in JSON files that are structured by locale since this is a popular format that can be imported into a TMS. While it's recommended to have at least the messages for the default locale available locally (e.g. for [type-safe messages](/docs/workflows/typescript#messages)), you can also load messages dynamically, e.g. from a CDN that your TMS provides. **Content Management Systems (CMS)** diff --git a/docs/pages/docs/usage/configuration.mdx b/docs/pages/docs/usage/configuration.mdx index 70f0f5399..8a7ad9fe4 100644 --- a/docs/pages/docs/usage/configuration.mdx +++ b/docs/pages/docs/usage/configuration.mdx @@ -199,7 +199,7 @@ The most crucial aspect of internationalization is providing labels based on the ... ``` -Colocating your messages with app code is beneficial because it allows developers to make changes quickly and additionally, you can use the shape of your local messages for [type checking](/docs/workflows/typescript). Translators can collaborate on messages by using CI tools, such as Crowdin's GitHub integration, which allows changes to be synchronized directly into your code repository. +Colocating your messages with app code is beneficial because it allows developers to make changes quickly and additionally, you can use the shape of your local messages for [type checking](/docs/workflows/typescript#messages). Translators can collaborate on messages by using CI tools, such as Crowdin's GitHub integration, which allows changes to be synchronized directly into your code repository. That being said, `next-intl` is agnostic to how you store messages and allows you to freely define an async function that fetches them while your app renders: @@ -500,6 +500,12 @@ function Component() { } ``` + + You can optionally [specify a global type for + `formats`](/docs/workflows/typescript#formats) to get autocompletion and type + safety. + + Global formats for numbers, dates and times can be referenced in messages too: ```json filename="en.json" diff --git a/docs/pages/docs/workflows/typescript.mdx b/docs/pages/docs/workflows/typescript.mdx index 49afd6df1..1a7ba0f28 100644 --- a/docs/pages/docs/workflows/typescript.mdx +++ b/docs/pages/docs/workflows/typescript.mdx @@ -2,7 +2,13 @@ import Callout from 'components/Callout'; # TypeScript integration -`next-intl` integrates with TypeScript out-of-the box without additional setup. You can however provide the shape of your messages to get autocompletion and type safety for your namespaces and message keys. +`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. + +## Messages + +Messages can be strictly typed to ensure you're using valid keys. ```json filename="messages.json" { @@ -12,7 +18,7 @@ import Callout from 'components/Callout'; } ``` -```tsx filename="About.tsx" +```tsx function About() { // ✅ Valid namespace const t = useTranslations('About'); @@ -21,13 +27,13 @@ function About() { t('description'); // ✅ Valid message key - return

{t('title')}

; + t('title'); } ``` To enable this validation, add a global type definition file in your project root (e.g. `global.d.ts`): -```jsx filename="global.d.ts" +```ts filename="global.d.ts" import en from './messages/en.json'; type Messages = typeof en; @@ -40,10 +46,83 @@ declare global { 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. -**If you're encountering problems, please double check that:** +## 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. + +```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'); +} +``` + +To enable this validation, export the formats that you're using in your request configuration: + +```ts filename="i18n/request.ts" +import {getRequestConfig} from 'next-intl/server'; +import {Formats} from 'next-intl'; + +export const formats = { + dateTime: { + short: { + day: 'numeric', + month: 'short', + year: 'numeric' + } + }, + number: { + precise: { + maximumFractionDigits: 5 + } + }, + list: { + enumeration: { + style: 'long', + type: 'conjunction' + } + } +} satisfies Formats; + +export default getRequestConfig(async ({locale}) => { + // ... + + return { + 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: + +```ts filename="global.d.ts" +import {formats} from './src/i18n/request'; + +type Formats = typeof formats; + +declare global { + // Use type safe formats with `next-intl` + interface IntlFormats extends Formats {} +} +``` + +## Troubleshooting + +If you're encountering problems, please double check that: -1. Your interface is called `IntlMessages`. +1. Your interface uses the correct name. 2. You're using TypeScript version 4 or later. -3. The path of your `import` is correct. +3. You're using correct paths for all modules you're importing into your global declaration file. 4. Your type declaration file is included in `tsconfig.json`. 5. Your editor has loaded the most recent type declarations. When in doubt, you can restart. diff --git a/examples/example-app-router-playground/global.d.ts b/examples/example-app-router-playground/global.d.ts index b749518b9..15004afe0 100644 --- a/examples/example-app-router-playground/global.d.ts +++ b/examples/example-app-router-playground/global.d.ts @@ -1,8 +1,13 @@ 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 {} } 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 19ed49fe1..9d52d239d 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 {useNow, useTimeZone, useLocale} from 'next-intl'; +import {useNow, useTimeZone, useLocale, useFormatter} from 'next-intl'; import {Link, usePathname} from '@/i18n/routing'; export default function ClientContent() { @@ -18,3 +18,23 @@ 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/components/AsyncComponent.tsx b/examples/example-app-router-playground/src/components/AsyncComponent.tsx index 5d3626df7..ec219840a 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 {getTranslations} from 'next-intl/server'; +import {getTranslations, getFormatter} from 'next-intl/server'; export default async function AsyncComponent() { const t = await getTranslations('AsyncComponent'); @@ -15,9 +15,27 @@ export default async function AsyncComponent() { export async function TypeTest() { const t = await getTranslations('AsyncComponent'); + const format = await getFormatter(); + // @ts-expect-error await getTranslations('Unknown'); // @ts-expect-error t('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/i18n/request.tsx b/examples/example-app-router-playground/src/i18n/request.tsx index dbb30daed..f2ffe6e95 100644 --- a/examples/example-app-router-playground/src/i18n/request.tsx +++ b/examples/example-app-router-playground/src/i18n/request.tsx @@ -1,9 +1,31 @@ import {headers} from 'next/headers'; import {notFound} from 'next/navigation'; +import {Formats} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import defaultMessages from '../../messages/en.json'; import {routing} from './routing'; +export const formats = { + dateTime: { + medium: { + dateStyle: 'medium', + timeStyle: 'short', + hour12: false + } + }, + number: { + precise: { + maximumFractionDigits: 5 + } + }, + list: { + enumeration: { + style: 'long', + type: 'conjunction' + } + } +} satisfies Formats; + export default getRequestConfig(async ({locale}) => { // Validate that the incoming `locale` parameter is valid if (!routing.locales.includes(locale as any)) notFound(); @@ -22,15 +44,7 @@ export default getRequestConfig(async ({locale}) => { globalString: 'Global string', highlight: (chunks) => {chunks} }, - formats: { - dateTime: { - medium: { - dateStyle: 'medium', - timeStyle: 'short', - hour12: false - } - } - }, + formats, onError(error) { if ( error.message === diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index da80b6588..2638472a0 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -163,7 +163,9 @@ 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?: string | DateTimeFormatOptions + formatOrOptions?: + | Extract + | DateTimeFormatOptions ) { return getFormattedValue( formatOrOptions, @@ -183,7 +185,9 @@ 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?: string | DateTimeFormatOptions + formatOrOptions?: + | Extract + | DateTimeFormatOptions ) { return getFormattedValue( formatOrOptions, @@ -200,7 +204,9 @@ export default function createFormatter({ function number( value: number | bigint, - formatOrOptions?: string | NumberFormatOptions + formatOrOptions?: + | Extract + | NumberFormatOptions ) { return getFormattedValue( formatOrOptions, @@ -284,7 +290,9 @@ export default function createFormatter({ type FormattableListValue = string | ReactElement; function list( value: Iterable, - formatOrOptions?: string | Intl.ListFormatOptions + formatOrOptions?: + | Extract + | Intl.ListFormatOptions ): Value extends string ? string : Iterable { const serializedValue: Array = []; const richValues = new Map(); diff --git a/packages/use-intl/src/react/useFormatter.test.tsx b/packages/use-intl/src/react/useFormatter.test.tsx index 88b1124d4..51170256f 100644 --- a/packages/use-intl/src/react/useFormatter.test.tsx +++ b/packages/use-intl/src/react/useFormatter.test.tsx @@ -3,12 +3,7 @@ import {parseISO} from 'date-fns'; import React, {ComponentProps, ReactNode, ReactElement} from 'react'; import {spyOn, SpyImpl} from 'tinyspy'; import {it, expect, describe, vi, beforeEach} from 'vitest'; -import { - DateTimeFormatOptions, - IntlError, - IntlErrorCode, - NumberFormatOptions -} from '../core'; +import {IntlError, IntlErrorCode} from '../core'; import IntlProvider from './IntlProvider'; import useFormatter from './useFormatter'; @@ -30,7 +25,7 @@ describe('dateTime', () => { function renderDateTime( value: Date | number, - options?: DateTimeFormatOptions + options?: Parameters['dateTime']>['1'] ) { function Component() { const format = useFormatter(); @@ -94,6 +89,16 @@ describe('dateTime', () => { screen.getByText('11 AM'); }); + it('accepts type-safe custom options', () => { + // eslint-disable-next-line no-unused-expressions + () => + renderDateTime(mockDate, { + dateStyle: 'full', + // @ts-expect-error + timeStyle: 'unknown' + }); + }); + describe('time zones', () => { it('converts a date to the target time zone', () => { renderDateTime(mockDate, { @@ -284,7 +289,10 @@ describe('dateTime', () => { }); describe('number', () => { - function renderNumber(value: number | bigint, options?: NumberFormatOptions) { + function renderNumber( + value: number | bigint, + options?: Parameters['number']>['1'] + ) { function Component() { const format = useFormatter(); return <>{format.number(value, options)}; @@ -327,6 +335,16 @@ describe('number', () => { screen.getByText('10000'); }); + it('accepts type-safe custom options', () => { + // eslint-disable-next-line no-unused-expressions + () => + renderNumber(2, { + currency: 'USD', + // @ts-expect-error + currencySign: 'unknown' + }); + }); + describe('performance', () => { beforeEach(() => { vi.spyOn(Intl, 'NumberFormat'); @@ -412,10 +430,15 @@ describe('number', () => { }); describe('relativeTime', () => { - function renderNumber(date: Date | number, now: Date | number) { + function renderRelativeTime( + date: Date | number, + nowOrOptions: Parameters< + ReturnType['relativeTime'] + >['1'] + ) { function Component() { const format = useFormatter(); - return <>{format.relativeTime(date, now)}; + return <>{format.relativeTime(date, nowOrOptions)}; } render( @@ -426,7 +449,7 @@ describe('relativeTime', () => { } it('can format now', () => { - renderNumber( + renderRelativeTime( parseISO('2020-11-20T10:36:00.000Z'), parseISO('2020-11-20T10:36:00.100Z') ); @@ -434,7 +457,7 @@ describe('relativeTime', () => { }); it('can format seconds', () => { - renderNumber( + renderRelativeTime( parseISO('2020-11-20T10:35:31.000Z'), parseISO('2020-11-20T10:36:00.000Z') ); @@ -442,7 +465,7 @@ describe('relativeTime', () => { }); it('can format minutes', () => { - renderNumber( + renderRelativeTime( parseISO('2020-11-20T10:12:00.000Z'), parseISO('2020-11-20T10:36:00.000Z') ); @@ -450,7 +473,7 @@ describe('relativeTime', () => { }); it('uses the lowest unit possible', () => { - renderNumber( + renderRelativeTime( parseISO('2020-11-20T09:37:00.000Z'), parseISO('2020-11-20T10:36:00.000Z') ); @@ -458,7 +481,7 @@ describe('relativeTime', () => { }); it('can format hours', () => { - renderNumber( + renderRelativeTime( parseISO('2020-11-20T08:30:00.000Z'), parseISO('2020-11-20T10:36:00.000Z') ); @@ -466,7 +489,7 @@ describe('relativeTime', () => { }); it('can format days', () => { - renderNumber( + renderRelativeTime( parseISO('2020-11-17T10:36:00.000Z'), parseISO('2020-11-20T10:36:00.000Z') ); @@ -474,7 +497,7 @@ describe('relativeTime', () => { }); it('can format weeks', () => { - renderNumber( + renderRelativeTime( parseISO('2020-11-02T10:36:00.000Z'), parseISO('2020-11-20T10:36:00.000Z') ); @@ -482,7 +505,7 @@ describe('relativeTime', () => { }); it('can format months', () => { - renderNumber( + renderRelativeTime( parseISO('2020-03-02T10:36:00.000Z'), parseISO('2020-11-20T10:36:00.000Z') ); @@ -490,7 +513,7 @@ describe('relativeTime', () => { }); it('can format years', () => { - renderNumber( + renderRelativeTime( parseISO('1984-11-20T10:36:00.000Z'), parseISO('2020-11-20T10:36:00.000Z') ); @@ -513,6 +536,16 @@ describe('relativeTime', () => { screen.getByText('34 years ago'); }); + it('accepts type-safe custom options', () => { + // eslint-disable-next-line no-unused-expressions + () => + renderRelativeTime(parseISO('2020-11-20T10:36:00.000Z'), { + unit: 'day', + // @ts-expect-error + style: 'unknown' + }); + }); + describe('performance', () => { let RelativeTimeFormat: SpyImpl; beforeEach(() => { @@ -600,7 +633,7 @@ describe('relativeTime', () => { describe('list', () => { function renderList( value: Iterable, - options?: Intl.ListFormatOptions + options?: Parameters['list']>['1'] ) { function Component() { const format = useFormatter(); @@ -699,6 +732,16 @@ describe('list', () => { screen.getByText('apple, banana, & orange'); }); + it('accepts type-safe custom options', () => { + // eslint-disable-next-line no-unused-expressions + () => + renderList([], { + type: 'conjunction', + // @ts-expect-error + localeMatcher: 'unknown' + }); + }); + describe('performance', () => { let ListFormat: SpyImpl; beforeEach(() => { diff --git a/packages/use-intl/types/index.d.ts b/packages/use-intl/types/index.d.ts index 86a172bd1..d21023b4e 100644 --- a/packages/use-intl/types/index.d.ts +++ b/packages/use-intl/types/index.d.ts @@ -1,7 +1,15 @@ // This type is intended to be overridden -// by the consumer for optional type safety +// 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 {