TypeScript を使ったシンプルで型安全な i18n 実装

最近 Node.js な Web アプリケーションの多言語対応について調べる機会があったので、自分なりの考えをまとめておく。
結論としては、特にライブラリを使わず、自前の実装で片付けてしまうことにした。
誰でも思いつきそうな至極単純な発想なのだけれど、調べた限りこういう情報が出てこなかったので、記事にすることで誰かのお役に立てれば幸い。

# 既存ライブラリに対する不満: 静的型付を実現できない[^1]

TypeScript ユーザーにとっては、以下のようなものがコンパイル時ではなく、 実行時のエラーになるのは耐え難い。

* message の key の typo
* message の引数の typo, 過不足, 型違い
* 異なる言語間で message の定義に一貫性がない

ピンと来ない人向けに、それなりに人気のありそうな i18next という package の記法を例として書いておく。

## [参考] [i18next](http://i18next.com/) の記法

辞書ファイル (JSON):

```json
{
  "en": {
    "translation": {
      "key1": "value of key1 in en",
      "interpolation": "{{what}} is {{how}}",
      "dateFormat": "The current date is {{date, MM/DD/YYYY}}",
      "keyWithCount": "{{count}} item",
      "keyWithCount_plural": "{{count}} items"
    }
  },
  "ja": {
    "translation": {
      "key1": "日本語の鍵１の値",
      "interpolation": "{{what}} は {{how}} です",
      "dateFormat": "今日は {{date, YYYY/MM/DD}} です",
      "keyWithCount": "{{count}} 個のアイテム"
    }
  }
}
```

※ `dateFormat` のところは、 format 用の関数の実装を別途用意しなければならないが、割愛している (詳細は http://i18next.com/translate/formatting/ )。

利用方法:

```js
i18next.t('key1'); // -> value of key1 in en
i18next.t('interpolation', { what: 'i18next', how: 'great' }); // -> i18next is great
i18next.t('dateFormat', { date: new Date() }); // -> The current date is 08/20/2016
i18next.t('keyWithCount', { count: 0 }); // -> 0 items
i18next.t('keyWithCount', { count: 1 }); // -> 1 item
i18next.t('keyWithCount', { count: 2 }); // -> 2 items
```

細かい説明は省くが、この使い方を見れば、上で挙げたようなものがコンパイルエラーにならないことはご理解いただけると思う。

# 辞書を JSON で定義するのが間違い、 TypeScript で書こう

arrow 関数と string template literal を使えば、 JSON と同等の可読性を持った辞書ファイルを TypeScript で記述できる。

```ts
export const messages = {
  key1: 'value of key1 in en',
  interpolation: (what: string, how: string) => `${what} is ${how}`,
  dateFormat: (date: Date) => `The current date is ${formatDate(date, 'MM/DD/YYYY')}`,
  keyWithCount: (count: number) => `${count} item${count === 1 ? '' : 's'}`
};

function formatDate(date: Date, format: string): string {
  // format 実装
}
```

# 言語に応じた利用辞書の解決

ライブラリを使う場合、このあたりは簡単な方法が提供されているはずだが、ここを自前で実装する必要がある。
しかし、大した手間ではない。

## 辞書ファイルの配置

```
src
  |
  |- messages
       |
       |- index.ts // 後述
       |
       |- en.ts // 英語の辞書ファイル
       |
       |- ja.ts // 日本語の辞書ファイル
       |
       |- // その他の対応言語の辞書ファイル...
```

## server side

src/messages/index.ts を以下のように書いておき、言語に応じた辞書を動的に解決できるようにしておく。

```ts
import * as path from 'path';
import * as glob from 'glob';
import { messages as en } from './en';

export const messages = en;

export namespace Server {
  // src/messages 下に存在する (index.ts 以外の) ファイルが対応言語を表す
  export const acceptableLanguages = glob.sync(`${__dirname}/*.js`)
    .map((file) => path.basename(file, '.js'))
    .filter((language) => language !== 'index');

  // それぞれの言語を require, キャッシュしておく
  const map = acceptableLanguages.reduce((acc, language) => {
    acc[language] = require(`./${language}`).messages;
    return acc;
  }, {} as {[language: string]: typeof messages});

  /**
   * 指定された言語に対応する辞書を返す
   */
  export function messagesOf(language: string): typeof messages {
    return map[language];
  }
}
```

すると、以下のようにして request の言語に対応する辞書を取得できる (Express の `req.acceptsLanguages` を使った例)。

```ts
import * as express from 'express';
import { Server } from './messages';

const
  app = express(),
  DEFAULT_LANGUAGE = 'en';

app.get('/', (req: express.Request, res: express.Response) => {
  const
    language = (req.acceptsLanguages(Server.acceptableLanguages) || DEFAULT_LANGUAGE) as string,
    messages = Server.messagesOf(language);

  // ...
});
```

## client side

`webpack.NormalModuleReplacementPlugin` を利用して、 `require('./path/to/messages')` がビルド時に対応言語ごとに `require('./path/to/messages/{language}')` として扱われるようにする。

```js
// webpack.config.js
const webpack = require('webpack');
const { Server } = require('./src/messages');

module.exports = Server.acceptableLanguages.map((language) => ({
  entry: './src/client.js',
  output: {
    path: './build',
    filename: `bundle_${language}.js` // 言語ごとにバンドルした JS を出力
  },
  plugins: [
    new webpack.NormalModuleReplacementPlugin(/^\..*\/messages$/, (result) => result.request += `/${language}`)
  ]
}));
```

server side の template で、 request の言語に応じたバンドル済み JS をアサインする。

```ts
const language = (req.acceptsLanguages(Server.acceptableLanguages) || DEFAULT_LANGUAGE) as string;

// ...

<script type='text/javascript' charSet='utf-8' src={`bundle_${language}.js`}></script>
```

こうしておくと、 client side の TS からは、単に以下のように書くだけで必要な辞書を取得できる。

```ts
// 一応説明しておくと、下記は import { messages } from './path/to/messages/index'; と同じ意味に解釈されるので、
// コンパイル時は src/messages/index.ts に定義した messages の型に基づいた型チェックが行われる
import { messages } from './path/to/messages';
```

## server side rendering

server side rendering を行う場合、 component のコードは server side でも client side でも動くことになるため、 component の中から直接辞書を取得してはならない (server side と client side で辞書の解決方法が異なるため)。

そこで、辞書を引数にして、最上位の component を返すような関数を定義する。

```ts
// React の場合
import * as React from 'react';
import { messages } from './messages';

export const createApp = (_messages: typeof messages) =>
  class extends React.Component<{ name?: string; unread?: number; }, void> {
    render(): JSX.Element { 
      return <div> 
        <h1>{_messages.title}</h1> 
        <p>{_messages.greeting(this.props.name)}</p> 
        <p>{_messages.unreadNotification(this.props.unread)}</p> 
      </div>; 
    } 
  }
;
```

server side, client side, それぞれのやり方で取得した辞書をこの関数に渡すことで、最上位の component を取得すればよい。
子 component からは、最上位の component から props や context (または利用する view のライブラリに応じた何らかの機構) によって伝播された辞書を使うようにする。

# 動作する例

上記の内容を実装したものを、以下の repository に上げた

https://github.com/kimamula/ts-i18n

[^1]: 私の調査が甘いだけかもしれないので、実現できるものをご存知の方がいたら教えてください