エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

TypeScriptの型でなんかすごくがんばる

エンジニアリンググループの冨岡です。

先日、以下のTypeScript用DIライブラリを公開しました。大きな特徴として、解決するために不十分な依存がある場合にコンパイルエラーになるようになっています。

github.com

www.m3tech.blog

このコンパイル時の型チェックの実現には、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は今回最重要といってもいいテクニックなのでしっかり抑えておきましょう。

(使用例)

f:id:jooohn:20190420215312p:plain

f:id:jooohn:20190420215520p:plain

f:id:jooohn:20190420215638p:plain

さて、ひとまず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ですね!新しく出てきたのが、extendsinferといったキーワードです。これは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 ... })を返す

f:id:jooohn:20190420222846p:plain

依存している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すごいですね!

f:id:jooohn:20190420225239p:plain

これはこのセクションの下の方でサラッと例が載っているのですが、もうちょっとはっきり載っている箇所があれば教えてください。

この、ObjectTypeの値のUnionを取る機能はとても便利なので、簡単に使えるようにしておきましょう。

type Values<T> = T[keyof T]

これを用いて、依存するproperty一覧はこのようにして導出できます。

type DependentKeys<T> = Values<{
  [P in keyof DependencyMap<T>]: keyof DependencyMap<T>[P]
}>

f:id:jooohn:20190420225835p:plain

いよいよ依存一覧を導出する

DependentKeysを用いて依存一覧を導出する型を途中まで書いてみましょう。

type ShouldResolve<T> = {
  [P in DependentKeys<T>]: DependentValue<T, P>
}

// Tにおいて、依存するキーKの型を導出する
type DependentValue<T, K> = ???

今回の例において、DependentValueKには'age' | 'name' | 'firstName' | 'lastName'のいずれかがバインドされるはずです。 また、先程定義したDependencyMapをあらためて見てみましょう。

DependencyMap<DesignInternal> ==
  {
    user: { age: number, name: string }
    name: { firstName: string, lastName: string }
  }

例えば、ageからDependencyMapnumberを導出することができるでしょうか。

もう見ながら解説したほうが早いので実装を見てみましょう。

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)

f:id:jooohn:20190420234936p:plain

userプロパティが依存するagenumber型が取り出せました!素晴らしいですね!

f:id:jooohn:20190420234850p:plain

より安全な実装

...が、この方法には一つ落とし穴があります。ageに期待する型が複数あった場合に、Union型になってしまうのです。

f:id:jooohn:20190420235147p:plain

これでは、依存を注入する際に、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を除いた型を返してくれます。

f:id:jooohn:20190421001545p:plain

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> };

f:id:jooohn:20190421002426p:plain

期待どおり、すべての満たすべき依存を返してくれていますね!

f:id:jooohn:20190421002546p:plainf:id:jooohn:20190421002546p:plain

同一キーに要求する型が複数ある場合はneverにしてくれています。

不足している依存を導出する

長い道のりでしたがすべての依存を導出することができました!一番大変なところは終わりです!ここからすでにDesignとして定義されているプロパティを除外すれば、不足している依存を導出することができます。

素朴な実装

まずは素朴な実装として、ShouldResolveから単純にすでにDesignに存在しているプロパティを除外したものを試してみましょう。

type NaiveRequirements<T> = {
  [P in Exclude<keyof ShouldResolve<T>, keyof T>: ShouldResolve<T>[P]
}

f:id:jooohn:20190421004923p:plain

いい感じですね!

より安全な実装

だいたい良さそうに見えますが、そもそもの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
}

完成!!!

ついに、不足している依存を解決できるようになりました。

f:id:jooohn:20190421011446p:plain

依存する型に複数の候補ある場合はnever型のインスタンスを要求します。

f:id:jooohn:20190421011754p:plain

解決する型が要求する型と合っていない場合はnever型のインスタンスを要求します。

f:id:jooohn:20190421011633p:plain

結構typesafeになったのではないでしょうか?

型レベルプログラミングのテストについて

ここまでコンパイル時にいろいろがんばってきました。ところでこれらのテストはどう記述するのが良いでしょうか?

コンパイルが通り正しく動くこと、のテストは普段どおりの記述で良いので簡単です。しかし今回のように込み入ったことをした場合、「これがコンパイルに通らないこと」というテストが書きたくなってきます。テスト自体のコンパイルが通らないので困ってしまいますね。

これに関しては正直まだ知見が溜まっていません。TypeScriptから提供されているこれらのAPIを利用したテストを書くのが良さそうですが、もし他にも良いアイデア・実践例があれば是非教えてください!

TypeScript-wiki/Using-the-Compiler-API.md at master · microsoft/TypeScript-wiki · GitHub

まとめ

長くなりましたが、型安全なDIライブラリを作るために使ったTypeScriptのテクニックを紹介しました。TypeScriptは既存の無茶苦茶なインターフェースのJSライブラリに対応するため、表現力豊かな型システムを持っています。おかげで今回のような要求を満たした型を導出することができました。

エンジニア募集です!

エムスリーでは、ここまで読み進めてくださったような型大好きエンジニアを募集しています!