Node.js
I18n
TypeScript

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

More than 1 year has passed since last update.

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

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

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

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

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

[参考] i18next の記法

辞書ファイル (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": "日本語の鍵1の値",
      "interpolation": "{{what}} は {{how}} です",
      "dateFormat": "今日は {{date, YYYY/MM/DD}} です",
      "keyWithCount": "{{count}} 個のアイテム"
    }
  }
}

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

利用方法:

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 で記述できる。

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 を以下のように書いておき、言語に応じた辞書を動的に解決できるようにしておく。

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 を使った例)。

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}') として扱われるようにする。

// 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 をアサインする。

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 からは、単に以下のように書くだけで必要な辞書を取得できる。

// 一応説明しておくと、下記は 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 を返すような関数を定義する。

// 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. 私の調査が甘いだけかもしれないので、実現できるものをご存知の方がいたら教えてください