TypeScript Advent Calendar 2017 25日目の記事です。
はじめに
みなさんも型レベルで条件分岐したいですよね?
つい先日も Type-level TypeScript という記事が上がっていましたし、gcanti/typelevel-ts というライブラリもあるということがすべてを物語っています。
現状ではある型パラメーター T
が A
という型だったらのような分岐は(そのままでは)できません。TypeScript の issue としてもそのようなコードを書きたいというものが作られていて熱いです1。
ここではどのようなワークアラウンドを用いるとそのような制限を突破できるかを紹介したいと思います。
内容
やりたいこと
意味がある例を用いることで理解の助けになるとおもうので、まずはどういうことがやりたいのかを説明します。
先日、Play と戯れていたらなぜか構文解析をしていた件について という記事を書きました。内容は、 REST API を作り、作った API のバリデーションエラーを適切な構造に変換するというもので、単純化すると以下の通りです。
// API にはこのような形式でリクエストを送る
interface CreateUserRequest {
name: string,
favoriteNumbers: number[],
address: {
country: string,
prefecture: string
}
}
// API からこのようなデータを受け取れる(変換済み)
interface CreateUserErrorResponse {
name?: string,
favoriteNumbers?: string[],
address?: {
country?: string,
prefecture?: string
}
}
さて、Request
から ValidationErrorResponse
への変換規則はそう大したものではありませんがこんなことを手で書くわけにはいきません(面倒なので)。
考えられる選択肢としては TypeScript のコードを生成する何か2を用意するか Mapped Types のようなものを利用して TypeScript 内で完結するかです。
そして、圧倒的に後者のほうが取り回しやすそうだったので今回の内容となるわけです。
欲しいものは次の条件を満たす ErrorResponse
になります。
-
ErrorResponse<CreateUserRequest>
とCreateUserErrorResponse
が等価3
動くもの
天下り式に TypeScript 2.5.x 以上で動くものを見てみましょう。(2.5.x 未満でなぜ動かないかは未調査)
// hack
declare global {
interface Object {
'**T**': never;
'**kind**': 'Object';
}
interface Array<T> {
'**T**': T;
'**kind**': 'Array';
}
interface Number {
'**T**': never;
'**kind**': 'Number';
}
interface String {
'**T**': never;
'**kind**': 'String';
}
interface Boolean {
'**T**': never;
'**kind**': 'Boolean';
}
}
// リクエストに使われる型の定義
type BaseRequestSingleType = Number | String | Boolean;
type BaseRequestArrayType = Array<any>;
type BaseRequestObjectType<T> = {
[P in keyof T]: BaseRequestSingleType | BaseRequestArrayType | BaseRequestObjectType<T[P]>
};
type BaseRequestTypes<T> = BaseRequestSingleType | BaseRequestArrayType | BaseRequestObjectType<T>
// 条件分岐を再帰的に行う
type ErrorResponseCase<T extends BaseRequestTypes<T>> = {
'Object': ErrorResponse<T>,
'Array': ErrorResponseCase<T['**T**']>[],
'Number': string,
'String': string,
'Boolean': string
}[T['**kind**']];
export type ErrorResponse<T extends BaseRequestObjectType<T>> = {
[P in keyof T]?: ErrorResponseCase<T[P]>
};
interface CreateUserRequest {
name: string,
favoriteNumbers: number[],
address: {
country: string,
prefecture: string
}
}
const e: ErrorResponse<CreateUserRequest> = {
name: '',
favoriteNumbers: [''],
address: {
country: ''
}
}
中身
indexed access operator でいろいろやるのはまあいいのですが、なにやら declare global
などという不穏な文言が見えますね
-
**T**
: Array の T を保持しています。型レベルの演算で取り出せるとよいのですが…… -
**kind**
: 今回取り扱っている型を見分けるために string literal type を保持しています
後者に関しては type-level function application などが入ればネストは増えますがハックがなくても書けるようになるはず。
global
に何かしている部分について目をつぶればあとは割と素直なコードになっていると思います。
-
ErrorResponse
は型パラメータとして渡されたオブジェクトのプロパティそれぞれにErrorResponseCase
を適用します -
ErrorResponseCase
は型パラメータとして渡された型のプロパティ**kind**
を取り出し、その型に応じて動作を分けます(条件分岐)
-
Object
なら再帰的にErrorResponse
を適用します -
Array
なら**T**
の型をもとにErrorResponseCase
を適用した結果の配列を返します -
Number
,String
,Boolean
なら終端なのでstring
を返します
まとめ
冒頭で述べていたワークアラウンドとは declare global
することだったのです
というわけで型に応じて型レベルで条件分岐する方法は分かっていただけたと思うのでこれで終わりたいと思います。