TypeScript

TypeScript で型に応じた型レベル条件分岐がしたい

More than 1 year has passed since last update.

TypeScript Advent Calendar 2017 25日目の記事です。

はじめに

みなさんも型レベルで条件分岐したいですよね?
つい先日も Type-level TypeScript という記事が上がっていましたし、gcanti/typelevel-ts というライブラリもあるということがすべてを物語っています。

現状ではある型パラメーター TA という型だったらのような分岐は(そのままでは)できません。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 などという不穏な文言が見えますね :innocent:

  1. **T**: Array の T を保持しています。型レベルの演算で取り出せるとよいのですが……
  2. **kind**: 今回取り扱っている型を見分けるために string literal type を保持しています

後者に関しては type-level function application などが入ればネストは増えますがハックがなくても書けるようになるはず。

global に何かしている部分について目をつぶればあとは割と素直なコードになっていると思います。

  1. ErrorResponse は型パラメータとして渡されたオブジェクトのプロパティそれぞれに ErrorResponseCase を適用します
  2. ErrorResponseCase は型パラメータとして渡された型のプロパティ **kind** を取り出し、その型に応じて動作を分けます(条件分岐)
    • Object なら再帰的に ErrorResponse を適用します
    • Array なら **T** の型をもとに ErrorResponseCase を適用した結果の配列を返します
    • Number, String, Boolean なら終端なので string を返します

まとめ

冒頭で述べていたワークアラウンドとは declare global することだったのです :innocent:
というわけで型に応じて型レベルで条件分岐する方法は分かっていただけたと思うのでこれで終わりたいと思います。


  1. https://github.com/Microsoft/TypeScript/issues/12424 

  2. 静的なコードジェネレーター、babel などのようにコンパイル時のものなどいろいろ考えられそう 

  3. ここでは展開しきった時に同じ形になるくらいの意味です