LoginSignup
13
2

More than 1 year has passed since last update.

TypeScript の黒魔術を駆使して型で守られた i18n 実装を作る

Last updated at Posted at 2022-12-17

はじめに

こういう感じの i18n 実装があるとします。

const dictionaryJA = {
  editor: {
     placeholders: {
       title: "タイトル",
       tag: "タグを入力 (例: Ruby)"
     },
     slideMode: "スライドモード",
     submit: ({ group } : { group: string }) => `${group} に投稿`,
  },
}

const dictionaries = {
  ja: dictionaryJA,
  // ...
}


translate(dictionaries, "ja.editor.sliceMode") // => スライドモード
translate(dictionaries, "ja.editor.placeholders.title") // => タイトル
translate(dictionaries, "ja.editor.invalidKey") // Error (translation missing)
translate(dictionaries, "ja.editor.submit", { group: "Qiita" }) // => Qiita に投稿
translate(dictionaries, "ja.editor.submit") // Error (parameter missing)

i18n ライブラリのインターフェイスとしては (key を分割して渡したりとかはありますが) まあ見る方で、ここの Error になるケースを型で拾ってくれたらなと思いますよね?
実は TypeScript でチェックできます。 この記事では、どうやって TypeScript でチェックしてくれるように型をつけるか、どういう理屈で型がつくか、の解説をします。(複雑な型使うのどうなの?という話もちょっと書きます。)

答え

というわけで最初に答えを見せます。これです。(TypeScript PlayGroud へ飛びます)

実は類題が Stack Overflow にあり、これが型をつけるヒントになります。

回答に載っているこれらの便利な型 (特に Flatten) を使っていきます。

// Returns R if T is a function, otherwise returns Fallback
type IsFunction<T, R, Fallback = T> = T extends (...args: any[]) => any ? R : Fallback

// Returns R if T is an object, otherwise returns Fallback
type IsObject<T, R, Fallback = T> = IsFunction<T, Fallback, (T extends object ? R : Fallback)>

// "a.b.c" => "b.c"
type Tail<S> = S extends `${string}.${infer T}`? Tail<T> : S;

// typeof Object.values(T)
type Value<T> = T[keyof T]

// {a: {b: 1, c: 2}} => {"a.b": {b: 1, c: 2}, "a.c": {b: 1, c: 2}}
type FlattenStepOne<T> = {
  [K in keyof T as K extends string ? (IsObject<T[K], `${K}.${keyof T[K] & string}`, K>) : K]: 
    IsObject<T[K], {[key in keyof T[K]]: T[K][key]}>
};

// {"a.b": {b: 1, c: 2}, "a.c": {b: 1, c: 2}} => {"a.b": 1, "a.c": 2}
type FlattenStepTwo<T> = {[a in keyof T]:  IsObject<T[a], Value<{[M in keyof T[a] as M extends Tail<a> ? M : never]: T[a][M] }>>}

// {a: {b: 1, c: {d: 1}}} => {"a.b": 1, "a.c": {d: 1}}
type FlattenOneLevel<T> = FlattenStepTwo<FlattenStepOne<T>>

// {a: {b: 1, c: {d: 1}}} => {"a.b": 1, "a.b.c.d": 1}
type Flatten<T> = T extends FlattenOneLevel<T> ? T: Flatten<FlattenOneLevel<T>>

以下のように translate の型をあてていきます。

// 辞書を作成する際は `as const` をつけておく
const dictionaryJA = { /* ... */ } as const
const dictionaries = { /* ... */ } as const

type Dictionary = { [K in string]: Dictionary | string | ((...args: any) => string) }
type I18nParam<F> = F extends ((...args: any) => any) ? Parameters<F> : []


function translate<D extends Dictionary, K extends keyof Flatten<D> & string, P extends I18nParam<Flatten<D>[K]>>(dictionaries: D, key: K, ...params: P): string {
  const flattenedDictionary = flattenDictionaries(dictionaries)
  const value = flattenedDictionary[key]

  switch (typeof value) {
    case 'function':
      if (params.length >= value.length) {
        return value.apply(null, params)
      } else {
        throw `parameter missing: ${key}`
      }
    case 'string':
      return value
    default:
      throw `translation missing: ${key}`
  }
}

// {a: {b: "text", c: {d: (key) => `${key} text` }}} => {"a.b": "text", "a.b.c.d": (key) => `${key} text` }
function flattenDictionaries<D extends Dictionary>(dictionaries: D): Flatten<D>{
  // ここは話が長くなるので省略 (実装自体は難しくない)
}

…という感じで型をつけることは出来ました。とはいえ、なんかよくわからん型をそのまま使うのも怖いですし、応用も効かないですよね。というわけでどういう理屈で型をつけられたのか解説していきます。

解説

まず問題を以下の2つに分解します。

  • flatten ( { a: { b: "text" } } => { "a.b": "text" } のようにネストしたオブジェクトを平にする) したオブジェクトの型を推論できるようにする
  • flatten された i18n 辞書の型が推論されている前提で、 key, params が妥当かを型で検証できるようにする

flatten された i18n 辞書の、十分強い型があれば key, params が妥当か型で検証できる

まず後者から考えると、以下のように flatten された I18n辞書が以下の通り、リテラルとして書かれているという前提があれば、 key, params の制約を型で表現するのは難しくありません。

// as const をつけると、これの type が { [string]: string | Function } みたいに丸められなくなる
const flattenDictionaries = {
  "ja.editor.placeholders.title": "タイトル",
  "ja.editor.placeholders.tag": "タグを入力 (例: Ruby)",
  "ja.editor.slideMode": "スライドモード",
  "ja.editor.submit": ({ group } : { group: string }) => `${group} に投稿`,
} as const


type D = typeof flattenDictionaries
type I18nParam<F> = F extends ((...args: any) => any) ? Parameters<F> : []

function translateFlatten<K extends keyof D, P extends I18nParam<D[K]>>(key: K, ...params: P) {
  const value = flattenDictionaries[key]

  switch (typeof value) {
    case 'function':
      if (params.length >= value.length) {
        return value.apply(null, params)
      } else {
        throw `parameter missing: ${key}`
      }
    case 'string':
      return value
    default:
      throw `translation missing: ${key}`
  }
}

このコードのように、 flattenDictionaries の型に、具体的なプロパティとその値の型の対応が残っていれば、

  • key: flattenDictionaries のいずれかのプロパティである (keyof typeof flattenDictionaries)
  • params: flattenDictionaries[key] が function ならその引数、そうでない (string) なら空
    • ※ こういった型の世界での条件分岐は Conditional Types を使って表現できます

という制約を型でチェックできます。

flatten された i18n 辞書の型を生成する Flatten<T> を理解する

flattenDictionaries の型に、具体的なプロパティとその値の型の対応が残っていれば、 key, params の妥当性は型でチェックできることがわかりました。

では、flattenDictionaries を直接リテラルとして書いているのではなく、何らかの変換によって生まれた値の場合はどうすればいいのでしょうか?
そこで出てくるのが最初に見せた摩訶不思議な Flatten<T> になります。

const flattenedDictionaries = flattenDictionaries(dictionaries)
function flattenDictionaries<T>(dictionaries: T): Flatten<T> { /* ... */}

このコードの flattenDictionaries の型を <T>(dictionaries: T) => Flatten<T> として、この Flatten<T> に具体的なプロパティの情報が入っていればよいということになります。

どうすれば Flatten<T> が具体的なプロパティの情報を残せるかを見ていこうと思います。

前提知識: TypeScript の特殊な型

まず、 Flatten<T> を理解する上でいくつか特殊な型を知っておく必要があります。

※ Generics とか割と一般よりのは流石に説明すると長くなるのでスキップします。そのへんは TypeScript Handbook とかに目を通すのがおすすめです。

(infer なども理解には必要なのですが、これは具体例ベースの方が良いと思うので後で説明します)

Template Literal Types

まずは Template Literal Types からです。 Template Literal Types は以下のような type 版 Template Literal です。これの偉いところは Union Type や Generics などと組み合わせると、以下のようにいい感じに型を推論してくれるところですね。

type Hello = 'Hello'
type World = 'World'
type HelloWorld = `${Hello}, ${World}` // type `Hello, World`

type People = 'Ami' | 'Mike'
type HelloPeople = `${Hello}, ${People}` // type `Hello, Ami` | `Hello, Mike` 

function concatWithDot<A extends string, B extends string>(a: A, b: B): `${A}.${B}` { `${a}.${b}` }
concat("namespace", "key") // type "namespace.key"

flatten した i18n 辞書の "ja.editor.placeholders.title" みたいなプロパティはこれを使えば作れそうです。

Mapped Types

次に Mapped Types ですが、 これは一言でいうと、オブジェクトの型の map 関数、内包記法みたいなものです。

どういう意味かについては以下の記事を見てもらうとして、

具体的にどういうシーンで使えそうかと言うと、例えば、以下の Getters<Person> のように Generic Type や Template Literal Type などと組み合わせて、あるオブジェクト型から、各プロパティとその値を一定の法則で弄った別の型を作りたい、みたいなシーンで使えます。

type Getters<Type> = {
    [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]
}

type Person = {
    name: string,
    age: number,
    location: string
}

type PersonGetters = Getters<Person>
// type { getName: () => string, getAge: () => number, getLocation: () => string }

今回だと、 Template Literal と組み合わせて、i18n の辞書から、それを flatten したものの型を作るのに Mapped Type が使えそうです。

Flatten<T> の型コードリーディング

Template Literal Types と Mapped Types を使えば、 Flatten<T> としてプロパティの情報を含んだ型が上手く作れそうな気がするというところまでは分かりました。

ただ、ちょっと気になることとして、

  • flattenDictionaries は 1回 map をすればいいわけではなく、ネストしたオブジェクトがなくなるまで map を繰り返す必要がある
  • ja.editor.placeholders.title というプロパティ名をつなげるために、異なるネストレベルのプロパティ名をそれぞれ取り出す必要がある

あたりがあり、そう単純には済まなそうです。
ということでそろそろ実際に Flatten<T> の実装を見ていくことにしましょう。 (名前を見て何やってるか、どう実装すればいいか簡単に分かりそうな型の定義は省略してます)

// Returns R if T is a function, otherwise returns Fallback
type IsFunction<T, R, Fallback = T> = T extends (...args: any[]) => any ? R : Fallback

// Returns R if T is an object, otherwise returns Fallback
type IsObject<T, R, Fallback = T> = IsFunction<T, Fallback, (T extends object ? R : Fallback)>

// "a.b.c" => "b.c"
type Tail<S> = S extends `${string}.${infer T}`? Tail<T> : S;

// typeof Object.values(T)
type Value<T> = T[keyof T]

// {a: {b: 1, c: 2}} => {"a.b": {b: 1, c: 2}, "a.c": {b: 1, c: 2}}
type FlattenStepOne<T> = {
  [K in keyof T as K extends string ? (IsObject<T[K], `${K}.${keyof T[K] & string}`, K>) : K]: 
    IsObject<T[K], {[key in keyof T[K]]: T[K][key]}>
};

// {"a.b": {b: 1, c: 2}, "a.c": {b: 1, c: 2}} => {"a.b": 1, "a.c": 2}
type FlattenStepTwo<T> = {[a in keyof T]:  IsObject<T[a], Value<{[M in keyof T[a] as M extends Tail<a> ? M : never]: T[a][M] }>>}

// {a: {b: 1, c: {d: 1}}} => {"a.b": 1, "a.c": {d: 1}}
type FlattenOneLevel<T> = FlattenStepTwo<FlattenStepOne<T>>

// {a: {b: 1, c: {d: 1}}} => {"a.b": 1, "a.b.c.d": 1}
type Flatten<T> = T extends FlattenOneLevel<T> ? T: Flatten<FlattenOneLevel<T>>

再帰を使ってネストしているオブジェクトを平らにする

まずは Flatten<T> に注目します。

// {a: {b: 1, c: {d: 1}}} => {"a.b": 1, "a.b.c.d": 1}
type Flatten<T> = T extends FlattenOneLevel<T> ? T: Flatten<FlattenOneLevel<T>>

A extends B ? C : D の形式は Conditional Types (型の世界の条件分岐) で、片方は再帰になっているので、これは要するに以下のように、 「特定の条件 (T extends FlattenOneLevel<T>) を満たすまで FlattenOneLevel<T> を繰り返している」ということになります。

type Flatten<T> = FlattenOneLevel<FlattenOneLevel<FlattenOneLevel<FlattenOneLevel<FlattenOneLevel<.... FlattenOneLevel<T>>>>>>

条件節にも分岐先にも FlattenOneLevel があるので、まずはそれを見ていきましょう。

// {a: {b: 1, c: {d: 1}}} => {"a.b": 1, "a.c": {d: 1}}
type FlattenOneLevel<T> = FlattenStepTwo<FlattenStepOne<T>>

ここからはコメントも理解のヒントになると思います。 FlattenStepOneFlattenStepTwo での変換を順に行うことで、ネストを一段解消していることが分かります。

FlattenStepOneFlattenStepTwo の定義はやや複雑ですが、大まかには FlattenStepOne でネスト解消後のプロパティを用意して、 FlattenStepTwo では、各プロパティに対して辻褄が合うように値の変換を行っています。

FlattenStepOne<{a: {b: 1, c: 2}, d: 3}> // type: { "a.b": { b: 1, c: 2 }, "a.c": { b: 1, c: 2 }, d: 3 }
FlattenStepTwo<{ "a.b": { b: 1, c: 2 }, "a.c": { b: 1, c: 2 }, d: 3 }> // type: { "a.b": 1, "a.c": 2, d: 3 }

(具体的な説明はかなり複雑かつ長いので折りたたみます)

FlattenStepOne

まず FlattenStepOne から。

// {a: {b: 1, c: 2}} => {"a.b": {b: 1, c: 2}, "a.c": {b: 1, c: 2}}
type FlattenStepOne<T> = {
  [K in keyof T as K extends string ? (IsObject<T[K], `${K}.${keyof T[K] & string}`, K>) : K]: 
    IsObject<T[K], {[key in keyof T[K]]: T[K][key]}>
};

// Returns R if T is an object, otherwise returns Fallback
type IsObject<T, R, Fallback = T> = IsFunction<T, Fallback, (T extends object ? R : Fallback)>

// Returns R if T is a function, otherwise returns Fallback
type IsFunction<T, R, Fallback = T> = T extends (...args: any[]) => any ? R : Fallback

これは、プロパティ (key) の変換と値の変換をそれぞれ見ていくのが良いでしょう。

まずプロパティの方から。 [K in keyof A as B] という表現は、 K は A の各プロパティを表す、 B は変換後のプロパティを表すということになります。 (Ref: Key Remapping via
as
)

なので、変換後のプロパティは

K extends string ? (IsObject<T[K], `${K}.${keyof T[K] & string}`, K>) : K

になるということですが、ここはややこしいので具体例を考えます。

T: { a: { b: 1, c: 2} } (なので K: "a") で考えると、 T[K]: { b: 1, c: 2 } となるので、 ${K}.${keyof T[K] & string}: "a.b" | "a.c" となります。

(extends stringキーが string でないケース を想定して書かれているという理解ですが、 i18n の辞書に限れば string 以外は来ないので深く考えなくて良いでしょう)

なので、各プロパティの変換は、そのプロパティがネストしている (そのプロパティに対応した値がオブジェクト) なら "a.b", "a.c" のようにドットで結合した名前になり、ネストしてないならそのまま、となります。

keyof FlattenStepOne<{ a: { b: 1, c: 2}, d: 3,  }> // type "a.b" | "a.c" | "d"

値の変換は、各プロパティに対応した値が来るようになっています。

FlattenStepTwo

次に FlattenStepTwo の方。

// {"a.b": {b: 1, c: 2}, "a.c": {b: 1, c: 2}} => {"a.b": 1, "a.c": 2}
type FlattenStepTwo<T> = {[a in keyof T]:  IsObject<T[a], Value<{[M in keyof T[a] as M extends Tail<a> ? M : never]: T[a][M] }>>}

// "a.b.c" => "b.c"
type Tail<S> = S extends `${string}.${infer T}`? Tail<T> : S;

// typeof Object.values(T)
type Value<T> = T[keyof T]

これはまずやりたいことから考えるといいでしょう。 FlattenStepOne で変換したプロパティに対して、その値の辻褄を合わせたいというのがやりたいことです。
こうなるように FlattenStepTwo は定義されています。

FlattenStepOne<{a: {b: 1, c: 2}, d: 3}> // type: { "a.b": { b: 1, c: 2 }, "a.c": { b: 1, c: 2 }, d: 3 }
FlattenStepTwo<{ "a.b": { b: 1, c: 2 }, "a.c": { b: 1, c: 2 }, d: 3 }> // type: { "a.b": 1, "a.c": 2, d: 3 }

その前提で、ややこしい記法を説明していくと、

{[M in keyof T[a] as M extends Tail<a> ? M : never]: T[a][M] }

// 具体例
// T: { "a.b": { b: 1, c: 2 }, "a.c": { b: 1, c: 2 }, d: 3 }
// a: "a.b", "a.c", "d" の内それぞれ
// T[a]: { b: 1, c: 2 }, 3 の内それぞれ
// M: "b", "c" の内それぞれ

内の M extends Tail<a> ? M : never は条件 (M extends Tail<a>) を満たさないプロパティを消去することを表しています。 (Ref: Key Remapping via as)

type Tail<S> = S extends `${string}.${infer T}`? Tail<T> : S;

での、 infer は Conditional Types の特殊な記法で、T の型は、 Cnoditional Types の条件を満たすような型となります。

例えば、 S: "a.b" の場合は T: "b" となります。


…ということで、 FlattenOneLevel はネストを一段解消していることが分かりました。

最後に Flatten<T> の定義に戻ってきて、 T extends FlattenOneLevel<T> ? ... : ... の部分意味ですが、これは、解消するネストがなくなるまで FlattenOneLevel を繰り返していることを意味しています。

// {a: {b: 1, c: {d: 1}}} => {"a.b": 1, "a.b.c.d": 1}
type Flatten<T> = T extends FlattenOneLevel<T> ? T: Flatten<FlattenOneLevel<T>>

// 解消するネストがなくなるまで FlattenOneLevel を繰り返す
// type Flatten<T> = FlattenOneLevel<FlattenOneLevel<FlattenOneLevel<FlattenOneLevel<FlattenOneLevel<.... FlattenOneLevel<T>>>>>>
const T1: { a: { b: 1 }, c: 2 }
FlattenOneLevel<T1>: { "a.b": 1, c: 2 }
// 解消するネストがあると、  T1 と FlattenOneLevel<T1> は違うものになり、 T1 extends FlattenOneLevel<T1> を満たさない

const T2: { "a.b": 1, c: 2 }
FlattenOneLevel<T2>: { "a.b": 1, c: 2 }
// 解消するネストがないと、 T2 と FlattenOneLevel<T2> は変わらず、T1 extends FlattenOneLevel<T1> を満たす

…ということでまとめると、Flatten<T> では

  • ネストしたオブジェクトの flatten の挙動は、型レベルで、 Mapped Types によって具体的なプロパティの情報が残るような変換として表現されている。
    • プロパティ名の結合には Template Literal Types などを利用して行っている
  • ネストを解消しきるまでの flatten を繰り返すことを型で表現するために、再帰を利用している

ことが分かりました。

※複雑な型を使っていくのはありなのか

…というわけで、頑張って translate 関数の型で、 key, params が間違っているケース (translation missing あるいは parameter missing になるケース) を検出できるようにしました。
この手の複雑な型について、利用するのはありなのか、良くないのではという話が出がちだと思いますが、型で解決するというアプローチは最初から排除せずに、(これはよく言われていることですが) メリットデメリット、置かれている状況の理解、他のアプローチとの比較をした上で判断するのが良いかなと思います。

  • メリット
    • i18n の記述ミスを防ぎやすくなる、自動でチェックされるという点でアプリケーションの安全性の向上と、開発生産性の向上が見込める
    • (他の手法と比較して)
      • 型を利用するというアプローチは、プログラムの挙動に影響を与えないため、
        • 導入の際にバグなどによって安全性が落ちる可能性が小さい。(最悪)捨てることも容易。
        • (上手く型をあてられれば) 導入の際に既存の実装を置き換えなくて良い
      • 手で書く必要があるもの (具体的な型やそれに準ずるもの) が少ない。
      • 辞書オブジェクトを定義する際の制約が少なく、比較的自由に書きやすい
  • デメリット
    • 複雑な型は (ある意味パラダイムの違う別言語で Linter を書いているようなもので) コードを理解、変更することを難しくする (抽象的なコードの部分で顕著)
      • (のを多少でも緩和するためにこの解説記事を書いています)
    • 型推論が複雑になることにより、型検査にかかる時間が伸びる (かもしれない)
  • 他の手法の例
    • Flatten<T> が不要な設計に置き換える
      • translate 関数のインターフェイスを変更する
        • string 引数ではなく、素朴なプロパティアクセスに置き換えるなど
      • flattenDictionaries をリテラルとして生成出来るようにする
        • コード生成、最初からそう書くなど
      • etc
    • ちゃんとメンテしてくれそうな外部のライブラリを探してそれに投げる
    • eslint などによる静的解析を使う
    • 自動テストを書いてもらって検出する
    • etc
    • そもそも何もしない (i18n のエラーを自動でチェックすることを諦める)

また、どういう状況に置かれているかでベターな選択肢も変わりうると思います。例えば、自分がこのアプローチを検討した状況は

  • 今回の例の translate に近いインターフェイスの自前の実装が使われていて、既に利用されている箇所もそれなりに多い
  • 自動テストのカバレッジはそこまで高くなく、かといって 100% (あるいはそれに近い状態) を目指すつもりではない
  • フロントエンドのコードは今後書く頻度は多く、その際に i18n 周りで設定を失敗することも結構あると予想される

という状況であったため、実装の変更の少なさと、導入のリスクの小ささという点を考え、今回紹介したやり方の多少複雑な型でもいいので守るという選択肢を取りました。
(もちろん、既存コードゼロの状態から始める、みたいな状況だったり、もっと時間的な予算が取りやすい状況だったりでは、別の選択肢を可能性は大いにあります。)

ただ、静的型付けは静的解析の中でも信頼度が高く挙動もわかりやすく融通も効く方で、導入のデメリットも小さい (ゼロではない) ので割と選択肢に挙げても良いんじゃないかと考えています。

まとめ

一見型で見るのが難しそうだけど、意外と TypeScript で見れる例として i18n の実装とその解説、導入する際のメリットデメリットの検討をしました。この辺の実装には TypeScript の型のいろいろなテクニックが使われているので、(読むのは骨が折れますが) 結構参考になる部分も多いんじゃないかと思います。
実は TypeScript の型で見れるものは思ったよりも広いですし、状況によってはもしかすると選択肢としては入ってくることもなくはないので、可能性として頭に入れておくと良いかもしれません。


Dev トークも公開しているので、もしこの辺の話詳しく聞きたい、と感じていただけた方はオンライン or オフラインでも話せるので是非↓で「話したい」を押してみてください :information_desk_person:

13
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
2