TypeScript 型レベルプログラミング フリースタイルガイド

  • TypeScript の型レベルプログラミングのための真面目なスタイルガイドではありません. 型なしラムダ計算で喜ぶような人間が使うための諸刃の剣です.
  • この記事の内容は TypeScript の 2022 年 1 月時点での最新版である 4.5.4 に基づいています. 将来のバージョンでの妥当性は保証しません.
  • 「型〜」「〜型」という用語の「型」はしばしば省略します. 値レベルの話題は一切登場しません.
  • 以前作った型レベル Brainfuck インタプリタはこのスタイルに則っているので参考にどうぞ. いつまでこのネタを引きずるんですか?

パラメータに対して制約を付与しない

型定義のパラメータに対して extends を使って制約を付与すると, そのパラメータに与えられる引数を制約を満たすもののみに限定することができます.

例として, 以下の Append には string の部分型のみを与えることができます.

type Append<S extends string, T extends string> = `${S}${T}`;

// "foo" extends string, "bar" extends string はどちらも成り立つので OK
type A0 = Append<"foo", "bar">;

// 666 extends string は成り立たないのでエラー
type A1 = Append<"foo", 666>;
//                      ~~~
// Type 'number' does not satisfy the constraint 'string'.

一見便利そうですが, より込み入った型レベルプログラミングをしていると, この制約はむしろ不便になることがあります.

例えば上の Append を使って, タプルに含まれる文字列を全て連結する Concat を定義してみましょう.

type Concat<SS extends string[], S extends string = ""> =
    SS extends [] ? S
  : SS extends [infer T, ...infer TS] ? Concat<TS, Append<S, T>>
//                                             ~~            ~
// Type 'TS' does not satisfy the constraint 'string[]'.
//   Type 'unknown[]' is not assignable to type 'string[]'.
//     Type 'unknown' is not assignable to type 'string'.
// Type 'T' does not satisfy the constraint 'string'.
  : never;

定義は一見良さそうに見えますが, 上記の通りエラーが出てしまいます.

TypeScript の型変数に関する制約の導出は必ずしも完全ではありません. この例の場合, infer された TTS にはそれぞれ

  • T extends unknown
  • TS extends unknown[]

という制約しか導出されない (参考) ため, より強い制約を要求する AppendConcat には TTS をそのまま与えることができず, エラーになってしまうのでした.

これを真っ当な方法で解消するためには, 以下のように各変数に対して追加で conditional types を使うことで制約を強めるか,

type Concat<SS extends string[], S extends string = ""> =
    SS extends [] ? S
  : SS extends [infer T, ...infer TS] ?
      TS extends string[]
        ? (T extends string ? Concat<TS, Append<S, T>> : never)
        : never
  : never;

あるいは conditional types を書くのが面倒であれば, 以下のような As を用意して使う必要があります (実はこの定義は標準ライブラリの Extract と同じです).

type As<T, U> = T extends U ? T : never;

type Concat<SS extends string[], S extends string = ""> =
    SS extends [] ? S
  : SS extends [infer T, ...infer TS] ? Concat<As<TS, string[]>, Append<S, As<T, string>>>
  : never;

この問題については TypeScript の機能の限界が原因であるとも言えますが, 一方でもし型定義のパラメータに対する制約がなければ, 問題が発生する部分自体がなくなりそうです. それではここでいっそのことパラメータに対して制約を与えるのをやめてみるとどうでしょうか.

パラメータに対して制約を与えない場合, Append は以下のように定義できます.

type Append<S, T> = S extends string ? (T extends string ? `${S}${T}` : never) : never;

これだけではむしろ複雑になっているように見えますが, Concat のようにこれを使う場合に効果が現れてきます.

type Concat<SS, S = ""> =
    SS extends [] ? S
  : SS extends [infer T, ...infer TS] ? Concat<TS, Append<S, T>>
  : never;

とても素直に, エラーなく定義することができました.

このように, ある程度以上複雑な型レベルプログラミングをする場合には, 型定義のパラメータに対する制約をあえて付与しない方が, 全体としては綺麗な定義ができることが多いです. 型変数に対して本当に制約が必要になった場合は, その場で都度 conditional types を使って制約を強めましょう.

代償として, パラメータに間違った引数を渡せるようになってしまっていました. これについては必要に応じて人間様向けのインターフェースを用意することでお茶を濁しましょう.

type GentleConcat<SS extends string[], S extends string = ""> = Concat<SS, S>;

(余談: 再現する例を用意できなかったのですが (古い TypeScript のみの事象?), 再帰的型定義とパラメータの制約を組み合わせると, 引数がパラメータの制約を満たすかどうかの判定を行う際に, 意図せず無限ループしているという扱いになってしまうことがあったとうっすら記憶しています. パラメータに制約を付与しないのはこれを回避するためでもありました.)

2024-02-28 追記: TypeScript 4.7 以降, infer に対して extends で制約をかけることができるようになっています. そのためパラメータに制約を付与していても,

type Append<S extends string, T extends string> = `${S}${T}`;
type Concat<SS extends string[], S extends string = ""> =
    SS extends [] ? S
  : SS extends [infer T extends string, ...infer TS extends string[]] ? Concat<TS, Append<S, T>>
  : never;

のようにより冗長性の低い形での記述が可能です.

複合型は上界よりもコンストラクタを定義する

オブジェクトなどを使って, 複数の型をまとめて扱うことは珍しくないでしょう.

このような複合型を総称的に表現する方法の一つに, その上界を定義する方法があります. 例えば 2 次元上の点を扱う場合は以下のようになります.

// 上界
type Point = {
  x: unknown;
  y: unknown;
};

// 各種操作
type GetX<P> = P extends Point ? P["x"] : never;
type GetY<P> = P extends Point ? P["y"] : never;

// 使っていろいろ
type MyPoint = { x: 1; y: 2 };
type MyX = GetX<MyPoint>;

この書き方をしていると, ほぼ必ず以下のようなコンストラクタが欲しくなります.

type NewPoint<X, Y> = {
  x: X;
  y: Y;
};

理由の一つとして typo のリスクがあります. なんといっても GetX などの各種操作にはパラメータに対する制約がないわけですから (誰のせいですか?), もしプロパティ名を typo してしまってもエラーにならないので, コンストラクタがないと問題を埋め込んでしまう可能性が高まってしまいます.

もう一つは単純に, { x: 1; y: 2 } よりも NewPoint<1, 2> と書けた方が, 記法として簡便, かつ型の表す意味がわかりやすくなるためです.

実は, むしろコンストラクタのみを定義しておけば, 上界が必要になることはほぼありません.

type Point<X, Y> = {
  x: X;
  y: Y;
};

型の要素を取り出す際には, infer を使った conditional types でパターンマッチを行います.

type GetX<P> = P extends Point<infer X, infer _Y> ? X : never;
type GetY<P> = P extends Point<infer _X, infer Y> ? Y : never;

例外ケースには never を使う

ふたたび文字列を結合する型 Append を考えます.

type Append<S, T> = S extends string ? (T extends string ? `${S}${T}` : never) : never;

この定義では, 意図しない (string の部分型でない) 引数が与えられた場合は never を返すようになっています.

このように例外ケースに never を使うことの妥当性は, 以下のようなパラメータに対して制約のある, このスタイルガイドに沿っていないライブラリを利用する例で確認できます.

type AppendUpper<S, T> = Uppercase<Append<S, T>>;

ここで使っている Uppercase は標準ライブラリに含まれる型で, 引数に対して extends string を要求します.

AppendUpper が意図している引数は S extends stringT extends string ですが, これらの制約が満たされている正常ケースでは Append<S, T> extends string が満たされるはずなので, 上記のような AppendUpper の定義が素朴に行えると便利です.

ここでもし例外ケースに適当な型, 例えば unknown を使っていた場合, AppendUpper の定義は以下のようにエラーになります.

type Append<S, T> = S extends string ? (T extends string ? `${S}${T}` : unknown) : unknown;

type AppendUpper<S, T> = Uppercase<Append<S, T>>;
//                                 ~~~~~~~~~~~~
// Type 'Append<S, T>' does not satisfy the constraint 'string'.
//   Type 'unknown' is not assignable to type 'string'.

AppendUpper の定義の時点では, パラメータ S, T についての制約がありません. この場合 Append<S, T> extends string の判定には, Append 内の conditional types の全ての分岐の可能性が考慮されます.

例外ケースに unknown を使用していた場合, 全ての分岐を考慮した (`${S}${T}` | unknown | unknown) extends string (ここで S extends string, T extends string) が成り立つかどうかが調べられることになります. ここで unknown の性質 T | unknown = unknown と, unknown extends string が成り立たないという事実から, この制約は満たされないと判定され, AppendUpper の定義は上記の通りエラーとなってしまいます.

反対に, 例外ケースに never を返していた場合は, (`${S}${T}` | never | never) extends string (ここで S extends string, T extends string) が成り立つかどうかが調べられます. ここで never の性質 T | never = T と, `${S}${T}` extends string が成り立つ事実から, 制約が満たされると判定され, 問題なく AppendUpper の定義が行えます.

ここから明らかなように, 例外ケースは必ずしも never でなくても, 例えば extends string を満たすものであれば AppendUpper の定義は行えます. 一方で, そもそも意図しない引数が与えられたという例外ケースでどういった型を返すべきかに頭を使ってもしょうがないので, ほとんどの場合は何も考えず never を使っておくのが無難でしょう.

本当は throw types のようなものがあると理想です.

機械的なフォーマットを無効化する

残念ながら Prettier のような機械フォーマットは, 型レベルプログラミングのためのイディオムや conditional types の連続といった複雑な型定義には適していないことが多いです.

機械的なフォーマットの無効化を躊躇わず, 人間が読みやすいように自由に書きましょう.

2024-02-28 追記: Prettier 3.1 で導入された experimentalTernaries を有効にすると, conditional types の連続もかなり読みやすい形でフォーマットがされるようになっています. 今すぐこれを有効化して, 手でフォーマットなんてやめてしまいましょう.

まとま

とまと.