エンジニアリンググループの冨岡です。
先日、以下のTypeScript用DIライブラリを公開しました。大きな特徴として、解決するために不十分な依存がある場合にコンパイルエラーになるようになっています。
このコンパイル時の型チェックの実現には、TypeScriptにおける様々な型レベルのテクニックが用いられています。泥臭い試行錯誤の結果、なかなかhackyなこともやっていて面白い(?)ので、せっかくなので解説しようと思います。
もちろん、「もっとエレガントにできるよ」というアドバイスやプルリクも大歓迎です!皆さんも、自分だったらこうするなぁ、とか思いながら楽しんでいただければと思います!
(なお本記事ではこのバージョンのこのファイルをもとに解説していきます。)
前提と問題の設定
typesafe-di
では、各インスタンスのfactoryをkey-valueの形で保持しておき、最後に足りない依存を注入することで全てのインスタンスを解決するようになっています。
わかりやすくするために、具体的な例を見ていきましょう。設計図にあたるDesignは以下のような型の単純なラッパーになっています。
// type User = { age: number, name: string }; // key-valueの形で、keyに対応するfactoryを保持するイメージ type DesignInternal = { // userは { age: number, name: string } に依存してUser型のオブジェクトを作る user: Resource<User, { age: number, name: string }> // nameは{ firstName: string, lastName: string } に依存してstring型の値を作る name: Resource<string, { firstName: string, lastName: string }> } // Design<T> のTには上記DesignInternalのような形の型を期待している。
Resource
というのがでてきますが、コメントを参考にすればだいたい雰囲気はわかるんじゃないかと思います。
user
は{ age: number, name: string }
に依存しているname
はすでに定義されており、{ firstName: string, lastName: string }
に依存している
ここから、{ age: number, firstName: string, lastName: string }
が足りないということが(人間には)わかると思います。これを、なんとかしてジェネリックな型のまま導出するのが今回の問題です!
Mapped Type
まずは、先程後回しにしたResource
の定義を見ていきます。
// keyに紐付けるファクトリ的なもの。 type Resource<T, D> = { // ファクトリ。依存先を表す`Injector<D>`を用いて`T`を作る resolve: (injector: Injector<D>) => Promise<T> // 終了処理。今回の記事では重要ではない。 finalize: (t: T) => Promise<void> } type Injector<T> = { [P in keyof T]: Promise<T[P]> }
Resource<T, D>
は特に解説する必要はないと思います。
一方Injector<T>
では、見慣れない(人も多いであろう)謎のシンタックスが使われています。
これはMapped Typeと呼ばれる型のシンタックスで、{ [XXX in YYY]: ??? }
となるYYY
でとりうる候補をpropertyとする新しいobject typeを記述することができます。???
では今注目しているプロパティ名XXX
を使って型を記述することが可能で、T[XXX]
でT
のプロパティXXX
の値型を示すことができます。keyof T
はその名の通りT
のプロパティ一覧と考えて良いでしょう。
このMapped Typeは今回最重要といってもいいテクニックなのでしっかり抑えておきましょう。
(使用例)
さて、ひとまずDesignが保持している型<T>
に期待する型がわかったところで、いよいよ足りない型(Requirements<T>
)を導出して行きましょう!
依存一覧を導出する
まずは、すでにT
に定義されているか否かに限らず、全てのResource
が依存している型の&を取った型の導出を目指します。
type DesignInternal = { user: Resource<User, { age: number, name: string }> name: Resource<string, { firstName: string, lastName: string }> } // userの依存 { age: number, name: string } // nameの依存 { firstName: string, lastName: string } // のintersectionである { age: number, name: string, firstName: string, lastName: string } を導出する
依存にのみ注目する
依存一覧を取得するにあたって、Resource
は余計なものが多いですね。理解しやすくするためにも、key-依存
の形になるように変形しましょう。
type DependencyMap<T> = ??? // こうなることを目指す // DependencyMap<DesignInternal> == // { // user: { age: number, name: string } // name: { firstName: string, lastName: string } // }
これはTSの機能を使って簡単に導出できます。
type DependencyMap<T> = { [P in keyof T]: T[P] extends Resource<any, infer D> ? D : never }
全体は先程確認したMapped Typeですね!新しく出てきたのが、extends
、infer
といったキーワードです。これはTypeScriptのConditional Typeという型のシンタックスで、A extends B ? C : D
のように記述します。三項演算のようになっているので雰囲気はわかりますよね。ジェネリックな型T
がある形の場合と、そうでない場合で型を場合分けすることができます。型レベルのif
と考えるとわかりやすいでしょうか。この際、B
の位置でinfer
キーワードを用いると、C
の位置で再利用できます。
このテクニックもやはり重要なので抑えておきましょう。今回の例では以下のようにして、Resourceの第二型パタメータを取り出すことができます。
// userについて見ている場合 { 'user': Resource<User, { age: number, name: string }> extends Resource<any, infer D> ? D : never } // ① Resource<..., ...>はextendsの項であるResource<any, infer D>をextendしている(マッチする)。 // ② Resource<..., { age: number, name: string }> の { age... } の部分が infer Dとマッチするため、Dに { age... }がバインドされる。 // ③ extends ... ? D : never で、マッチしているためD(= { age ... })を返す
依存しているproperty一覧を導出する
現在 { age: number, name: string, firstName: string, lastName: string }
の導出を目指していますが、その形は以下のようになることが予想できます。
{ [P in 'age' | 'name' | 'firstName' | 'lastName']: ??? }
そこで、次はこのproperty一覧である 'age' | 'name' | 'firstName' | 'lastName'
の導出を目指しましょう!
まずは先程のDependencyMapから依存先のproeprty一覧だけに注目しましょう。
{ [P in keyof DependencyMap<T>]: keyof DependencyMap<T>[P] } // 以下が導出される // { // user: 'age' | 'name', // name: 'firstName' | 'lastName' // }
ここから、さらにkeyof DependencyMap
の値をとることで、取りうる値のUnion Typeが導出できます。TypeScriptすごいですね!
これはこのセクションの下の方でサラッと例が載っているのですが、もうちょっとはっきり載っている箇所があれば教えてください。
この、ObjectTypeの値のUnionを取る機能はとても便利なので、簡単に使えるようにしておきましょう。
type Values<T> = T[keyof T]
これを用いて、依存するproperty一覧はこのようにして導出できます。
type DependentKeys<T> = Values<{ [P in keyof DependencyMap<T>]: keyof DependencyMap<T>[P] }>
いよいよ依存一覧を導出する
DependentKeys
を用いて依存一覧を導出する型を途中まで書いてみましょう。
type ShouldResolve<T> = { [P in DependentKeys<T>]: DependentValue<T, P> } // Tにおいて、依存するキーKの型を導出する type DependentValue<T, K> = ???
今回の例において、DependentValue
のK
には'age' | 'name' | 'firstName' | 'lastName'
のいずれかがバインドされるはずです。
また、先程定義したDependencyMapをあらためて見てみましょう。
DependencyMap<DesignInternal> == { user: { age: number, name: string } name: { firstName: string, lastName: string } }
例えば、age
からDependencyMap
のnumber
を導出することができるでしょうか。
もう見ながら解説したほうが早いので実装を見てみましょう。
type DependentValue<T, K> = Values<{ [P in keyof DependencyMap<T>]: K extends keyof DependencyMap<T>[P] ? DependencyMap<T>[P][K] : never }>
だんだん闇のテクニック感が出てきました。
DependentValue<T, 'age'>
を例に見ていくことにしましょう。まず一番外のValues
はそのままにして中身を考えます。
DependencyMapも含めて展開したイメージは以下のようになります。
Values<{ user: 'age' extends keyof { age: number, name: string } ? { age: number, name: string }['age'] : never name: 'age' extends keyof { firstName: string, lastName: string } ? { firstName: string, lastName: string }['age'] : never }>
さらにextendsも展開してみましょう。
Values<{ user: { age: number, name: string }['age'] name: never }>
user
の依存の中にage
があり、name
の依存にはないことでextendsの結果が別れています。
最後まで展開するとこうです。
Values<{ user: number name: never }>
number | never
ここまでの処理を日本語で言うと、"各リソースにおいて、ageに依存している場合は期待するageの型、そうでない場合はneverとした場合のUnion"といったところでしょうか。
never
は値が存在しない型となっており、Unionの中では無視されます。(ただしnever
しか存在しない場合はnever
)
user
プロパティが依存するage
のnumber
型が取り出せました!素晴らしいですね!
より安全な実装
...が、この方法には一つ落とし穴があります。ageに期待する型が複数あった場合に、Union型になってしまうのです。
これでは、依存を注入する際に、number
の値を入れたときにstring
を要求している箇所が壊れてしまいます。
本当は複数の要求がある場合に、全てを満たすintersectionにできたりするとかっこよさそうですが、ここはあまり上手く導出することができず諦めました。代替案として、複数の候補が存在する場合はコンパイラエラーとなるようにしました。まあ一つのキーに対して別の型を要求するというシチュエーションは滅多にないと思うので悪くない選択だと思います(サブタイプを要求するみたいなことがあるかもしれない)。
ということで、同一キーに対して要求する型がUnion型の場合(複数の型が要求されている場合)にコンパイルエラーになることを目指します。
type ExactOneValue<T> = Values<{ [P in keyof T]: Exclude<Values<T>, T[P]> extends never ? T[P] : never };
Values
の特殊型として、候補がUnion型になっている場合にnever
を返すExactOneValue
を定義しました。ここで出てきているExclude<T, U>
は、なんとTypeScript標準で用意されているUtility型です。T
がunionの場合、U
を除いた型を返してくれます。
Exclude<Values<T>, T[P]>
は候補が複数ある場合はExclude<'a' | 'b', 'a'>
のようになり残りの候補を返し、候補が一つの場合はExclude<'a', 'a'>
となりnever
を返すようになります。これがnever
だった場合(=候補が1つしかない場合)のみ T[P]
を返し、そうでない場合はnever
を返すことになります。
これにより、age
の依存に複数の候補があった場合は { age: never }
を要求することになります。never
型を満たすインスタンスは作成できないので、コンパイルが通らなくなります。
さて、最終的にShouldResolve
は以下のようになりました。
type DependentValue<T, K> = ExactOneValue<{ [P in keyof DependencyMap<T>]: K extends keyof DependencyMap<T>[P] ? DependencyMap<T>[P][K] : never }>; type ShouldResolve<T> = { [P in DependentKeys<T>]: DependentValue<T, P> };
期待どおり、すべての満たすべき依存を返してくれていますね!
同一キーに要求する型が複数ある場合はnever
にしてくれています。
不足している依存を導出する
長い道のりでしたがすべての依存を導出することができました!一番大変なところは終わりです!ここからすでにDesign
として定義されているプロパティを除外すれば、不足している依存を導出することができます。
素朴な実装
まずは素朴な実装として、ShouldResolve
から単純にすでにDesign
に存在しているプロパティを除外したものを試してみましょう。
type NaiveRequirements<T> = { [P in Exclude<keyof ShouldResolve<T>, keyof T>: ShouldResolve<T>[P] }
いい感じですね!
より安全な実装
だいたい良さそうに見えますが、そもそものDesign
の時点で、"要求している型と既に定義されている型が違うケースがある"ということに目をつむっています。つまり、こういうケースです。
type UserName = { firstName: string, lastName: string } type User = { age: number, name: UserName } type DesignInternal = { // nameにUserName型を要求している user: Resource<User, { age: number, name: UserName }> // nameとしてstring型を解決しようとしている name: Resource<string, { firstName: string, lastName: string }> }
typesafe-di
と言うからにはこういうことは避けたいところです。そもそも作れなければいいのですが、まずは最終的に解決できなければ良いだろう、という感じで実装しました。(現状はこういう実装ですが、型が合わないDesignを作れなくすることも考えています。)
やりかたは先程のUnion型のときと同様で、要求にそってないプロパティをnever
型で要求してしまいます(= コンパイルできない)。
まずはDesign
の各プロパティと、解決される型のObjectTypeを導出します。
export type Container<T> = { [P in keyof T]: T[P] extends Resource<infer V, any> ? V : never };
もう慣れたものですね!
次に、要求する型と解決される型があっていないプロパティ一覧を導出します。
type ConflictedKeys<T> = Values<{ [P in Extract<keyof ShouldResolve<T>, keyof T>]: Container<T>[P] extends ShouldResolve<T>[P] ? never : P }>
Extract<T, U>
は、Exclude
と似ています。こちらもTypeScript標準で提供されており、Tの中からUにも存在ものだけを取り出します。
Valuesとの組み合わせにより、"解決される型が、要求する型をextendしていないプロパティ一覧"を返します。
先程の素朴な実装に加えて、これら一致しないキーをnever
型で要求してしまいましょう。
type Requirements<T> = NaiveRequirements<T> & { [P in ConflictedKeys<T>]: never }
完成!!!
ついに、不足している依存を解決できるようになりました。
依存する型に複数の候補ある場合はnever型のインスタンスを要求します。
解決する型が要求する型と合っていない場合はnever型のインスタンスを要求します。
結構typesafeになったのではないでしょうか?
型レベルプログラミングのテストについて
ここまでコンパイル時にいろいろがんばってきました。ところでこれらのテストはどう記述するのが良いでしょうか?
コンパイルが通り正しく動くこと、のテストは普段どおりの記述で良いので簡単です。しかし今回のように込み入ったことをした場合、「これがコンパイルに通らないこと」というテストが書きたくなってきます。テスト自体のコンパイルが通らないので困ってしまいますね。
これに関しては正直まだ知見が溜まっていません。TypeScriptから提供されているこれらのAPIを利用したテストを書くのが良さそうですが、もし他にも良いアイデア・実践例があれば是非教えてください!
TypeScript-wiki/Using-the-Compiler-API.md at master · microsoft/TypeScript-wiki · GitHub
まとめ
長くなりましたが、型安全なDIライブラリを作るために使ったTypeScriptのテクニックを紹介しました。TypeScriptは既存の無茶苦茶なインターフェースのJSライブラリに対応するため、表現力豊かな型システムを持っています。おかげで今回のような要求を満たした型を導出することができました。
エンジニア募集です!
エムスリーでは、ここまで読み進めてくださったような型大好きエンジニアを募集しています!