TypeScriptで型安全なi18n

こんにちは、みらい翻訳でフロントエンドエンジニアをしている@aoi2です。
今回は、シンプルで型安全なi18nライブラリの仕組みを考えてみたので紹介します。

## 既存のi18nライブラリ

・jsonの定義ファイルでキーを確認し、間違いなく入力する必要あり。間違えても実行するまでエラーにならない。

```js
t('MESSAGE.GREETING') // => "こんにちは"

t('MESAGE.GREETING')  // => "MESAGE.GREETING" や "" など
/*    ↑スペルミス     */
```

・言語の定義がファイルで別れていて、言語ごとに訳文が揃っているか判断しづらい。

```json:ja.json
{
    "GREETING": "こんにちは",
    "THANK_YOU": "ありがとう"
}
```

```json:en.json
{
    "GREETING": "Hello"
    /* THANK_YOUの定義忘れ */
}
```
 
## 型安全にしてみる

まず、使用する言語の一覧を配列とUnion型で同時に定義しておきます。

```ts
const LANGUAGE = ['ja', 'en', 'zh'] as const
type LANGUAGE = (typeof LANGUAGE)[number]
```



```ts
// 可変長引数を取ってテンプレートに埋め込む関数の型
type FunctionalTemplate = (...args: string[]) => string

// 文字列またはFunctionalTemplateを翻訳の1単位とする
type TranslationElement = string | FunctionalTemplate

// 文字列のみの全ての言語のセットの型
type StringAtom = {
    [key in LANGUAGE]: string
}

// テンプレート関数のみの全ての言語のセットの型
type TemplateAtom = {
    [key in LANGUAGE]: FunctionalTemplate
};
```
上記のセットをまとめたオブジェクトの型を定義します。
Template Literal Typeを活用して、keyの頭に`$`が付いていれば、そのvalueのセットが全てテンプレート関数であることを強制します。

```ts
type Table<T> = {
    [K in keyof T]: K extends `$${infer _}` 
    ? TemplateAtom 
    : StringAtom
}
```

以下の関数は、オブジェクト・リテラルを書く時に引数の型で強制させるための関数です。
翻訳リソースを書く時にこの関数を通すことで型チェックができます。

```ts
const asTable = <T>(table: Table<T>) => table
```

Proxyを使うので、終端となる型を定義しておきます。

```ts
type ProxyTerminal<T> = {
    [key in keyof Table<T>]: key extends `$${infer _}`
    ? FunctionalTemplate
    : string
}
```

本体です。

```ts
type Setting = {
    language: LANGUAGE,
}

class I18n<T extends Table<T>> {
    public readonly t: ProxyTerminal<T>

    constructor(private readonly table: T, private setting: Setting) {
        const proxyHandler = {
            get: (_: never, prop: keyof T): TranslationElement => {
                return this.resolver(prop)
            }
        }
        
        this.t = new Proxy(table, proxyHandler) as unknown as ProxyTerminal<T> // 型が一致しなくなるので調整
    }

    private resolver(prop: keyof Table<T>) {
        return this.table[prop][this.setting.language]
    }
} 
```

### Proxy

Proxyの詳細は以下を確認してください。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Proxy

簡単に言うとオブジェクトの振る舞いを改変できる機能です。利用する時に簡潔に記述できるため、使ってみました。
以下の例のように、括弧を書かなくて良くなります。

```jsx
const t = i18n.resolver;

<p>t(TABLE.MESSAGE)</p>
```

```jsx
const t = i18n.$;

<p>t.MESSAGE<p/>
```

## 使ってみる

このように翻訳リソースを定義しておきます。

```ts
const TABLE = asTable({
    MESSAGE: {
        ja: 'こんにちは',
        en: 'Hello',
        zh: '你好'
    },
    $GREET: {
        ja: (name) => `こんにちは、${name}`,
        en: (name) => `Hello, ${name}`,
        zh: (name) => `你好、${name}`
    }
})
```

```ts
const i18n = new I18n(TABLE, {language: "zh"})
const t = i18n.t

console.log(t.MESSAGE) // => "こんにちは"
console.log(t.$GREET('太郎')) // =>  "こんにちは、太郎"
```

## 型安全である例

```ts
const TABLE = asTable({
    MESSAGE: {
        ja: 'こんにちは',
        zh: '你好'
    }
})
```

`TS2741: Property 'en' is missing in type '{ ja: string; zh: string; }' but required in type 'StringAtom'.`

`en`の定義を忘れましたが、エラーになりました。

```ts
const TABLE = asTable({
    GREET: {
        ja: (name) => `こんにちは、${name}`,
        en: (name) => `Hello, ${name}`,
        zh: (name) => `你好、${name}`
    }
})
```

`TS2322: Type '(name: any) => string' is not assignable to type 'string'.`
各言語のvalueが関数の場合は、keyに`$`をつけていないとエラーになります。

```ts
const TABLE = asTable({
    $GREET: {
        ja: 'こんにちは',
        en: 'Hello',
        zh: '你好'
    }
})
```

`TS2322: Type 'string' is not assignable to type 'FunctionalTemplate'.`
各言語のvalueが文字列なのにkeyに`$`をつけているとエラーになります。

```ts
const TABLE = asTable({
    GREET: {
        ja: 'こんにちは',
        en: 'Hello',
        zh: '你好'
    }
})

const i18n = new I18n(TABLE, {language: "ja"})
const t = i18n.t

console.log(t.GREEEEET)
```
`TS2339: Property 'GREEEEET' does not exist on type 'ProxyTerminal{ GREET: unknown; }>>'.`

もちろん存在しないキーを参照しようとするとエラーになります。
IDEの補完も効くので便利ですね。

## おわりに

すべての言語をリソースに含むため、対応言語が増えるとファイルサイズが肥大化してしまうのはデメリットです。

それでは、安全で快適なi18nライフを。