Edited at

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