TypeScriptで多言語システムの型チェックを実装する
概要
多言語対応は一般的に翻訳関数に翻訳キーを渡すことで実装されます。その際に問題になるのが、キー名の間違い、キー名の変更漏れなどによる実装不備です。このようなエラーを人の目で全て確認するのは難しいため、システムによる検知が有効です。
この記事では、TypeScriptの型システムを活用した、型チェックの方法を説明します。これにより、正しい翻訳キーが割り当てられていることがコンパイル時にチェックされ、アプリケーションの品質向上に大きな効果が期待できます。
多言語システムの実装
1. ベースとなる型の定義
翻訳の型チェックは、日本語のメッセージファイル(ja.ts)をベースとして構築します。翻訳ファイルはJSONファイルで扱われることもありますが、型チェックという観点ではtsファイルで管理するのがおすすめです。
1 | import { jaMessage } from './ja' |
TypeScriptの機能:typeof演算子
typeof演算子は、値から型を抽出するTypeScriptの機能です。typeof jaMessageにより、jaMessageオブジェクトの構造から型を自動的に生成します。これにより、jaMessageの構造が変更されると、型定義も自動的に更新されます。
ja.tsのサンプルコード
以下は、日本語メッセージファイル(ja.ts)のサンプルです。このファイルが型定義のベースとなります。
1 | export const jaMessage = { |
このファイルでは、as constを使用することで、値がリテラル型として推論されます。これにより、型の精度が向上し、より厳密な型チェックが可能になります。
as constとは
as constは、TypeScriptの「constアサーション」という機能です。オブジェクトや配列にas constを付けることで、以下の効果が得られます。
- リテラル型として推論される: 値が具体的なリテラル型(例:
'マイページ')として推論されます - 読み取り専用になる: オブジェクトのプロパティが
readonlyになります - 型の精度が向上する: より具体的な型情報が保持されます
as constがない場合
as constがない場合、TypeScriptは値の型を一般的な型として推論します。
1 | // as const がない場合 |
この場合、typeof jaMessageで取得できる型は、値がstring型として推論されます。
as constがある場合
as constがある場合、TypeScriptは値の型をリテラル型として推論します。
1 | // as const がある場合 |
この場合、typeof jaMessageで取得できる型は、値が具体的なリテラル型('次へ'、'戻る')として推論されます。
なぜas constが必要なのか
翻訳メッセージの型チェックにおいて、as constは以下の理由で重要です。
- 型の一貫性:
typeof jaMessageで取得した型が、実際の値の構造を正確に反映します - 型の精度:
GenericTypeが適用される際、元の構造が正確に保持されます - 読み取り専用の保証: 翻訳メッセージが誤って変更されることを防ぎます
実際の違い
as constの有無による型の違いを確認してみましょう。
1 | // as const がない場合 |
as constがある場合、nextの型は'次へ'という具体的なリテラル型になります。これにより、型システムがより正確に動作し、翻訳キーの型チェックがより厳密になります。
2. 再帰的な型変換
翻訳メッセージはネストされたオブジェクト構造を持っています。この構造を型として表現するために、GenericTypeというユーティリティ型を使用しています。
1 | type GenericType<T extends object> = { |
TypeScriptの機能:条件型(Conditional Types)と再帰的な型
[K in keyof T]: マップ型(Mapped Types)により、オブジェクトの各キーを反復処理しますT[K] extends object ? GenericType<T[K]> : string: 条件型により、値がオブジェクトの場合は再帰的にGenericTypeを適用し、そうでない場合はstring型とします
これにより、以下のような構造が正しく型付けされます。
1 | { |
GenericTypeは再帰的に動作するため、何段階のネストでも正しく型付けされます。
3. 各言語ファイルでの型チェック
各言語ファイルでは、I18nMessage型を明示的に指定することで、型チェックが行われます。
1 | import { I18nMessage } from './types' |
型チェックの効果
必須キーが欠落している場合、コンパイルエラーが発生します
存在しないキーを追加しようとすると、エラーが発生します
値の型が
stringでない場合、エラーが発生しますキーのタイポがある場合、エラーが発生します
4. 翻訳関数の型安全な実装
翻訳関数はライブラリを利用してもよいですが、シンプルな翻訳システムであれば自前で実装するのもよいと思います。ここでは、必要最低限の翻訳関数のサンプルを紹介します。
翻訳関数についても型安全を担保する必要があります。型システムを実装するために、Paths型とPathValue型を使用します。
キーパスの型生成
1 | type PathsInternal<T> = T extends string |
この型により、'term.next'、'pages.home.title'のような文字列リテラル型が自動生成されます。
キーパスから値を取得する型
1 | export type PathValue<T, P extends string> = P extends `${infer Key}.${infer Rest}` |
この型により、キーパスから対応する文字列型を取得できます。
翻訳関数の実装
1 | import { I18nMessage, Paths, PathValue } from './types' |
使用例
翻訳関数の使用例です。
翻訳関数の使用
1 | import { createI18n } from './i18n' |
正しい使用例
1 | export const enMessage: I18nMessage = { |
エラーが発生する例
1 | export const enMessage: I18nMessage = { |
メリット
コンパイル時の安全性: 翻訳キーのエラーを実行前に検出できます
自動補完: IDEが翻訳キーを自動補完してくれます
リファクタリングの安全性: キー名を変更する際、使用箇所を自動的に検出できます
ドキュメントとしての役割: 型定義自体が、利用可能な翻訳キーのドキュメントとなります
翻訳関数の型安全: 翻訳関数を使用する際も、存在しないキーを指定するとコンパイルエラーが発生します
サンプルコード
サンプルコードは https://github.com/shoyan/i18n-sample で公開しています。