Skip to content

Commit

Permalink
Enable type-safe internationalization (and translation key autocomple…
Browse files Browse the repository at this point in the history
…tability) (#199)

## Ticket

Resolves #194 

## Changes

- Add a new `i18n-types` NPM script for generating a TypeScript
declaration of the JSON English locale file(s)
- [Configure i18next to be
type-safe](https://www.i18next.com/overview/typescript), to prevent
references to i18n key paths that don't exist in the locale files

## Context for reviewers

I'm interested to get feedback on this proposal. It comes with some
tradeoffs...

**Pros**

- Type-safe i18n! This is useful for catching translation errors early.
- In addition to type safety, it also enables autocomplete capability in
IDE's (see demo below).
- It's not always clear to teams how to enable type-safety of i18next,
so providing this out-of-the-box could be helpful for teams that want
it.

**Cons**

- This relies on a TypeScript file that references every English JSON
file. I can definitely see an engineer adding a new English locale file
and getting TypeScript errors when they attempt to reference keys in it,
because they're already running the development server and didn't know
to run `npm run i18n-types`.
- I don't love that this relies on a new dependency to generate the
TypeScript file. Instructing engineers in the README to manually update
this file also didn't feel like a great option.
- Like other TypeScript errors, the type errors that get shown for
invalid keys don't make it obvious that the error is due to the key not
existing in the locale definition. There is a nice feature where it can
sometimes include a "Did you mean" suggestion though (see demo below).

I _think_ the benefits of type-safe i18n is worth this added potential
for confusion, but I'm curious to get people's thoughts on that.

## Testing


https://github.com/navapbc/template-application-nextjs/assets/371943/78388995-f823-4168-996f-6db833a4b088

Example of the test coverage to help teams identify that the file needs
to be updated:

<img width="1142" alt="CleanShot 2023-08-19 at 10 10 01@2x"
src="https://github.com/navapbc/template-application-nextjs/assets/371943/4d7873f2-a689-4a7a-a786-1ef5286000de">
  • Loading branch information
sawyerh authored Aug 29, 2023
1 parent 3781fac commit 97a0a4b
Show file tree
Hide file tree
Showing 9 changed files with 134 additions and 23 deletions.
3 changes: 2 additions & 1 deletion app/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
.next/
node_modules/
storybook-static/
src/types/generated*

# Ignore USWDS static assets
public/uswds

# Test artifacts
coverage/
coverage/
4 changes: 3 additions & 1 deletion app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
│ ├── pages # Page routes and data fetching
│   │ ├── api # API routes (optional)
│   │ └── _app.tsx # Global entry point
│ └── styles # Sass & design system settings
│ ├── styles # Sass & design system settings
│ └── types # TypeScript type declarations
├── stories # Storybook pages
└── tests
```
Expand Down Expand Up @@ -122,6 +123,7 @@ npm run test-watch -- pages

- [TypeScript](https://www.typescriptlang.org/) is used for type checking.
- `npm run ts:check` - Type checks all files
- `npm run i18n-types` - Updates the i18n TypeScript declaration. You only need to run this if you've added a new English locale file (JSON files in `public/locales/en`). This runs automatically when you start the development server or build the application.
- [ESLint](https://eslint.org/) is used for linting. This helps catch common mistakes and encourage best practices.
- `npm run lint` - Lints all files and reports any errors
- `npm run lint-fix` - Lints all files and fixes any auto-fixable errors
Expand Down
50 changes: 36 additions & 14 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
"private": true,
"scripts": {
"build": "next build",
"prebuild": "npm run i18n-types",
"dev": "next dev",
"predev": "npm run i18n-types",
"format": "prettier --write '**/*.{js,json,md,mdx,ts,tsx,scss,yaml,yml}'",
"format-check": "prettier --check '**/*.{js,json,md,mdx,ts,tsx,scss,yaml,yml}'",
"i18n-types": "i18next-resources-for-ts toc -i ./public/locales/en -o ./src/types/generated-i18n-bundle.ts -c \"Run 'npm run i18n-types' to generate this file\"",
"lint": "next lint --dir src --dir stories --dir .storybook --dir tests --dir scripts --dir app --dir lib --dir types",
"lint-fix": "npm run lint -- --fix",
"postinstall": "node ./scripts/postinstall.js",
Expand Down Expand Up @@ -52,6 +55,7 @@
"eslint-plugin-testing-library": "^5.11.0",
"i18next-browser-languagedetector": "^7.0.2",
"i18next-http-backend": "^2.2.1",
"i18next-resources-for-ts": "^1.3.0",
"jest": "^29.5.0",
"jest-axe": "^8.0.0",
"jest-cli": "^29.5.0",
Expand Down
7 changes: 2 additions & 5 deletions app/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ import {
Header as USWDSHeader,
} from "@trussworks/react-uswds";

const primaryLinks: {
i18nKey: string;
href: string;
}[] = [
const primaryLinks = [
{
i18nKey: "nav_link_home",
href: "/",
Expand All @@ -20,7 +17,7 @@ const primaryLinks: {
i18nKey: "nav_link_health",
href: "/health",
},
];
] as const;

const Header = () => {
const { t, i18n } = useTranslation("common", {
Expand Down
10 changes: 10 additions & 0 deletions app/src/types/generated-i18n-bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Run 'npm run i18n-types' to generate this file
*/
import common from '../../public/locales/en/common.json';

const resources = {
common
} as const;

export default resources;
16 changes: 16 additions & 0 deletions app/src/types/i18next.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* @file Type-safe internationalization. See the internationalization.md
* doc file for more information.
*/
import "i18next";

import i18nConfig from "next-i18next.config";

import resources from "./generated-i18n-bundle";

declare module "i18next" {
interface CustomTypeOptions {
resources: typeof resources;
defaultNS: i18nConfig.defaultNS;
}
}
48 changes: 48 additions & 0 deletions app/tests/types/i18next.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Test to help ensure the generated i18n TypeScript file remains up to date
* with the English locale files that are present. Since the generation script
* is potentially a confusing aspect of the i18n approach, this test is intended
* to help bring visibility to its existence and help clarify why an engineering
* might be receiving type errors in their i18n code.
* @jest-environment node
*/
import fs from "fs";
import generatedEnglishResources from "src/types/generated-i18n-bundle";

/**
* Add a custom matcher so we can provide a more helpful test failure message
*/
function toHaveI18nNamespaces(received: string[], expected: string[]) {
const missingNamespaces = expected.filter(
(namespace) => !received.includes(namespace)
);

return {
pass: missingNamespaces.length === 0,
message: () => {
const missingNamespacesString = missingNamespaces.join(", ");
let message = `The src/types/generated-i18n-bundle.ts file is missing imports for these English namespaces: ${missingNamespacesString}`;
message += `\n\nYou can fix this by re-running "npm run i18n-types" to regenerate the file.`;
message += `\n\nYou likely added a new namespace to the English locale files but the i18n-types script hasn't ran yet.`;
message += `\n\nIt's important for the generated-i18n-bundle.ts file to be up to date so that you don't get inaccurate TypeScript errors.`;

return message;
},
};
}

expect.extend({ toHaveI18nNamespaces });

describe("types/generated-i18n-bundle.ts", () => {
it("includes all English namespaces", () => {
const i18nNamespaces = fs
.readdirSync("public/locales/en")
.map((filename) => filename.replace(".json", ""));

// Not adding a type declaration for this matcher since it is only used in this test
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
expect(Object.keys(generatedEnglishResources)).toHaveI18nNamespaces(
i18nNamespaces
);
});
});
15 changes: 13 additions & 2 deletions docs/internationalization.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,21 @@

## Managing translations

- Translations are managed in the `public/locales` directory, where each language has its own directory (e.g. `en` and `es`).
- [Namespaces](https://www.i18next.com/principles/namespaces) can be used to organize translations into smaller files. For large sites, it's common to create a namespace for each controller, page, or feature (whatever level makes most sense).
- Translations are managed as JSON files in the `public/locales` directory, where each language has its own directory (e.g. `en` and `es`).
- [Namespaces](https://www.i18next.com/principles/namespaces) can be used to organize translations into smaller files. For large sites, it's common to create a namespace for each controller, page, or feature (whatever level makes most sense). See "Type-safe translations" below for additional considerations when adding new namespaces.
- There are a number of built-in formatters based on [JS's `Intl` API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl) that can be used in locale strings, and custom formatters can be added as well. [See the i18next formatting docs for details](https://www.i18next.com/translation-function/formatting#built-in-formats).

### Type-safe translations

I18next is configured to report errors if you attempt to reference an i18n key path that doesn't exist in a locale file. Type-safe internationalization requires a bit of extra work to maintain, but it can be an extremely helpful tool for catching translation errors early.

#### How it works

1. An NPM script (`i18n-types`) transforms the JSON locale files into a generated TypeScript file: [`generated-i18n-bundle.ts`](../app/src/types/generated-i18n-bundle.ts)
1. [`types/i18next.d.ts`](../app/src/types/i18next.d.ts) configures i18next to use the generated TypeScript file as the source of truth for the available keys. If a key isn't in the generated file, TypeScript will report an error for the key.

[Learn more about using TypeScript with i18next](https://www.i18next.com/overview/typescript).

## Load translations

1. `serverSideTranslations` must be called in [`getStaticProps`](https://nextjs.org/docs/basic-features/data-fetching/get-static-props) or [`getServerSideProps`](https://nextjs.org/docs/basic-features/data-fetching/get-server-side-props) to load translations for a page.
Expand Down

0 comments on commit 97a0a4b

Please sign in to comment.