Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: Type-safe global formats #1346

Merged
merged 25 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7ab9a72
feat/typesafe-global-formats
dBianchii Sep 18, 2024
4c12355
fix: Make the type fallback to string
dBianchii Sep 18, 2024
090294d
fix: forgot keyof
dBianchii Sep 18, 2024
77e8de1
move to playground
dBianchii Sep 18, 2024
c56ee2f
place that space back
dBianchii Sep 18, 2024
d995336
Merge branch 'main' into feat/typesafe-global-formats
dBianchii Sep 18, 2024
a67f7ad
add tests to example-app-router-playground
dBianchii Sep 19, 2024
4f3fd08
add format test to useLocale()
dBianchii Sep 19, 2024
25ea538
fix typo and make sure I am using use while I useFormatter
dBianchii Sep 19, 2024
14fd584
Merge remote-tracking branch 'origin/main' into feat/typesafe-global-…
amannn Sep 20, 2024
3fa91e8
Minor cleanup:
amannn Sep 20, 2024
d38a229
Revert changes to lockfile
amannn Sep 20, 2024
1c7ef5f
Add docs
dBianchii Sep 21, 2024
f728ef0
Update docs/pages/docs/workflows/typescript.mdx
dBianchii Sep 23, 2024
e1ce5aa
Update docs/pages/docs/workflows/typescript.mdx
dBianchii Sep 23, 2024
88718fa
rm type import
dBianchii Sep 23, 2024
95a2f40
A few doc changes
dBianchii Sep 23, 2024
0b014b8
Add text to ## messages
dBianchii Sep 23, 2024
d43d1a6
Merge branch 'main' into feat/typesafe-global-formats
dBianchii Sep 23, 2024
506e7ce
better highlight
dBianchii Sep 23, 2024
c747f62
Merge branch 'feat/typesafe-global-formats' of https://github.com/dBi…
dBianchii Sep 23, 2024
9b3567b
Revert change to lockfile
amannn Sep 24, 2024
3789e76
Minor docs adjustments:
amannn Sep 24, 2024
b8388ef
Merge remote-tracking branch 'origin/main' into feat/typesafe-global-…
amannn Sep 24, 2024
cca5134
Fix autocomplete when formats are provided
amannn Sep 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/pages/docs/design-principles.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PartnerContentLink href="https://crowdin.com/">Crowdin</PartnerContentLink>.

`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)).

Expand All @@ -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 <PartnerContentLink href="https://crowdin.com/">Crowdin</PartnerContentLink> 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)**

Expand Down
8 changes: 7 additions & 1 deletion docs/pages/docs/usage/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PartnerContentLink name="localization-management-intro" href="https://store.crowdin.com/github">Crowdin's GitHub integration</PartnerContentLink>, 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 <PartnerContentLink name="localization-management-intro" href="https://store.crowdin.com/github">Crowdin's GitHub integration</PartnerContentLink>, 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:

Expand Down Expand Up @@ -500,6 +500,12 @@ function Component() {
}
```

<Callout>
You can optionally [specify a global type for
`formats`](/docs/workflows/typescript#formats) to get autocompletion and type
safety.
</Callout>

Global formats for numbers, dates and times can be referenced in messages too:

```json filename="en.json"
Expand Down
93 changes: 86 additions & 7 deletions docs/pages/docs/workflows/typescript.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
{
Expand All @@ -12,7 +18,7 @@ import Callout from 'components/Callout';
}
```

```tsx filename="About.tsx"
```tsx
function About() {
// ✅ Valid namespace
const t = useTranslations('About');
Expand All @@ -21,13 +27,13 @@ function About() {
t('description');

// ✅ Valid message key
return <p>{t('title')}</p>;
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"
dBianchii marked this conversation as resolved.
Show resolved Hide resolved
import en from './messages/en.json';

type Messages = typeof en;
Expand All @@ -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.
5 changes: 5 additions & 0 deletions examples/example-app-router-playground/global.d.ts
Original file line number Diff line number Diff line change
@@ -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 {}
}
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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');
}
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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');
}
32 changes: 23 additions & 9 deletions examples/example-app-router-playground/src/i18n/request.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -22,15 +44,7 @@ export default getRequestConfig(async ({locale}) => {
globalString: 'Global string',
highlight: (chunks) => <strong>{chunks}</strong>
},
formats: {
dateTime: {
medium: {
dateStyle: 'medium',
timeStyle: 'short',
hour12: false
}
}
},
formats,
onError(error) {
if (
error.message ===
Expand Down
24 changes: 20 additions & 4 deletions packages/use-intl/src/core/createFormatter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,11 @@ 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?:
| (keyof IntlFormats['dateTime'] extends string
? keyof IntlFormats['dateTime']
: string)
| DateTimeFormatOptions
) {
return getFormattedValue(
formatOrOptions,
Expand All @@ -183,7 +187,11 @@ 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?:
| (keyof IntlFormats['dateTime'] extends string
? keyof IntlFormats['dateTime']
: string)
| DateTimeFormatOptions
) {
return getFormattedValue(
formatOrOptions,
Expand All @@ -200,7 +208,11 @@ export default function createFormatter({

function number(
value: number | bigint,
formatOrOptions?: string | NumberFormatOptions
formatOrOptions?:
| (keyof IntlFormats['number'] extends string
? keyof IntlFormats['number']
: string)
| NumberFormatOptions
) {
return getFormattedValue(
formatOrOptions,
Expand Down Expand Up @@ -284,7 +296,11 @@ export default function createFormatter({
type FormattableListValue = string | ReactElement;
function list<Value extends FormattableListValue>(
value: Iterable<Value>,
formatOrOptions?: string | Intl.ListFormatOptions
formatOrOptions?:
| (keyof IntlFormats['list'] extends string
dBianchii marked this conversation as resolved.
Show resolved Hide resolved
? keyof IntlFormats['list']
: string)
| Intl.ListFormatOptions
): Value extends string ? string : Iterable<ReactElement> {
const serializedValue: Array<string> = [];
const richValues = new Map<string, Value>();
Expand Down
Loading
Loading