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 の連続もかなり読みやすい形でフォーマットがされるようになっています. 今すぐこれを有効化して, 手でフォーマットなんてやめてしまいましょう.

まとま

とまと.

状態は単一の経路を使って参照しよう

React アプリケーションにおいて single source of truth と言った場合, 複数のコンポーネントで同じ値が必要なときは, それぞれのコンポーネントで独立に状態を管理して互いに同期をとるのではなく, ただ一つの場所で状態を管理し, 全てのコンポーネントはそれを参照すべき, という設計のプラクティスとして説明されます.

There should be a single “source of truth” for any data that changes in a React application. Usually, the state is first added to the component that needs it for rendering. Then, if other components also need it, you can lift it up to their closest common ancestor. Instead of trying to sync the state between different components, you should rely on the top-down data flow.

https://reactjs.org/docs/lifting-state-up.html#lessons-learned

このプラクティスは状態をどう管理すべきかという視点に立ったもので, 実際これを守らないと各コンポーネントの状態の同期のために余計なコストがかかり, とても簡単にコードを機能不全に陥らせることができます.

このようにして単一の場所で状態を管理するところまでは良いのですが, そういった状態がしばしばコンポーネントツリーの外側に配置されるためか, 一つのコンポーネントが一つの状態を複数の経路で参照してしまうという現象が見られます. このような設計をしてしまった場合, single source of truth が守られなかった場合ほど深刻な問題にはなりにくいものの, 不必要な複雑さを招いてしまう可能性があります.

1. Props が単一の経路となっている場合

まずは素朴に, コンポーネントツリーに従った props のバケツリレーのみを使っている場合を考えます.

function Counter(props: {
  count: number;
  increment: () => void;
}): React.ReactElement {
  const { count, increment } = props;
  return (
    <p>
      <span>{count}</span>
      <button
        type="button"
        onClick={() => {
          increment();
        }}
      >
        +
      </button>
    </p>
  );
}

Counter コンポーネントは, props という単一の経路から, 状態への参照 (読み書き) である countincrement を受け取っています.

このコンポーネントは, 親コンポーネントが整合性を持った countincrement のペアを渡すことを要求します. とはいえコンポーネントのインターフェース (props) からこの要求を読み取ることはさほど難しいことではないでしょう.

この場合については (追加の文脈がなければ) 特に挙げられるような設計上の問題はなさそうです.

2. Hook が単一の経路となっている場合

続いて, 例えば useContext や Recoil などを用いて, コンポーネントツリーによらずに (props のバケツリレーから解放されて) 状態を参照できるような場合を考えます.

declare function useGlobalCount(): {
  count: number;
  increment: () => void;
};

function Counter(): React.ReactElement {
  const { count, increment } = useGlobalCount();
  return (
    <p>
      <span>{count}</span>
      <button
        type="button"
        onClick={() => {
          increment();
        }}
      >
        +
      </button>
    </p>
  );
}

この例でも, Counter コンポーネントは, useGlobalCount という単一の経路から, 状態への参照である countincrement を受け取っています. そしてやはり設計上に特に問題は見られません.

3. Props と Hook の両方の経路が使われている場合

同じくコンポーネントツリーによらない状態の参照を行う場合ですが, props と組み合わせるとどうでしょうか?

declare function useGlobalCount(): {
  count: number;
  increment: () => void;
};

function Counter(props: { count: number }): React.ReactElement {
  const { count } = props;
  const { increment } = useGlobalCount();
  return (
    <p>
      <span>{count}</span>
      <button
        type="button"
        onClick={() => {
          increment();
        }}
      >
        +
      </button>
    </p>
  );
}

Counter コンポーネントは props と hook の二つの経路から, それぞれ状態への参照である countincrement を取得しています. こういった実装は, 最初は count の表示だけを行なっていたコンポーネントに対し, 後から increment を行う機能が追加された場合などに, 不注意によって生まれてしまうことがあります. 何度も見たことがあります. 何度も...

このコンポーネントが正しく動作するためには, 親コンポーネントuseGlobalCount を使って取得した count を, そのままこのコンポーネントに渡すことが要求されます.

ところがこの要求はコンポーネントのインターフェースを見ただけではわからず, 具体的な実装やコメントなどの周辺的な情報まで立ち入って初めて読み取れることです. そのため, コンポーネントの利用者 (親コンポーネントの実装者) はそのことを理解するために余計な手間を強いられることになります.

さらに場合によっては親コンポーネントやそのまた親コンポーネントcount を props の一つとして受け取っており, この見えない要求はそういった先祖まで伝播してしまうことがあります. こうなるとコンポーネントの利用者は疑心暗鬼になり, 任意のコンポーネントに対してどういった props を渡すべきかを知るために全ての子孫コンポーネントの実装を読み解くことになるか, あるいは面倒になって開発をやめてしまうでしょう.

このようなコンポーネントの要求に伴う複雑さは, 同等の機能を持つ先の 2 つの例では全く存在しなかったことで, 本来不要なもののはずです. 状態への参照は props のみ, または hooks のみのように, 必ず経路を単一にすることで問題を回避しましょう.

最初に書いたような, コンポーネントに後から実装を追加するような場合には, 要求が満たされた状態から開始するため, 要求が増えたこと自体意識されないこともあるのかもしれません. しかし上記の通り, このようにして増えた要求はコードの理解の妨げになったり, あるいは理解しないまま変更することで将来的な不具合につながってしまう可能性が高いため, 十分に注意を払う必要があります.

まとめ

状態は単一の経路を使って参照しよう.

ところでたまたま React のコードで見かけたので React を使って説明しましたがこれはあくまで例で, こういった話は (single source of truth も含めて) Web フロントエンド, GUI アプリケーションといったものにも限らない, ごく一般的なプログラミングのプラクティスのはずです. 一般化大好き.