TL;DR

  • ジェネリック型は型に対する関数と考える。関数のパラメータを型アノテーションで制限するように、型パラメータは extends で制約する。
  • 可読性を上げる型パラメータ名を選び、TSDoc を書く。

内容

型空間の関数

型空間のジェネリック型は、値空間の関数に相当する(項目8 型空間のシンボルと値空間のシンボルの見分け方を知る)。重複を減らし、型レベルの演算をカプセル化できる。

extends で型パラメータを制約する

値空間で型アノテーションを使って関数のパラメータを制限するように、型空間では extends を使って型パラメータを制約できる。

type MyPick<T, K> = { [P in K]: T[P] } // 型エラー
type MyPick<T extends object, K extends keyof T> = { [P in K]: T[P] } // OK
 
type AgeOnly = MyPick<Person, "age"> // 存在するキー OK
type FirstNameOnly = MyPick<Person, "firstName"> // 存在しないキー 型エラー

制約を指定しないと型パラメータは unknown 扱いになり、どんな型でも渡せてしまう。noImplicitAny のように強制する仕組みもない。そのため、ジェネリック型を定義するときは制約を指定するよう意識する。

型パラメータの命名

型パラメータのスコープが小さければ、慣習どおり 1 文字の名前でもよい。クラスのようにスコープが大きい場合は、説明的な名前にしたほうがよい。

ジェネリック型の TSDoc

関数と同じく、ジェネリック型や型パラメータにも TSDoc を書く。関数の @param に当たるのは @template である。

/**
 * オブジェクト型のプロパティの一部を使用して新しいオブジェクト型を作る
 * (組み込みの `Pick` 型と同じ)
 * @template T 元のオブジェクト型
 * @template K 使用するキー、通常は文字列リテラル型のユニオン
 */
type MyPick<T extends object, K extends keyof T> = { [P in K]: T[P] }

ジェネリック関数とジェネリッククラス

ジェネリック関数やジェネリッククラスでは、ジェネリック型を概念的に定義する。これにより、実際の値が与えられたときに型推論が正しく働くよう手助けする。

クラスがプロパティを保持するように、ジェネリッククラスは型を保持する。そのため、ジェネリッククラスの型パラメータはインスタンス化のときに決まり、メソッドを呼ぶときには必要ない。ただし、メソッド自身が型パラメータを持つこともできる(項目28 クラスやカリー化を使って型パラメーターを段階的に割り当てる)。

関数との違い

関数は 1 つの値を返すが、ジェネリック型は値の集合を返す(項目7 型を値の集合として考える)。そのため、ジェネリック型を定義するときは、ユニオン型に対してどう振る舞うかを常に考える必要がある(項目53 条件型のユニオンでの分配を制御する)。

また、高階関数に当たるもの(高カインド型)は、今のところ表現できない。

type MapValues<T extends object, F> = {
  [K in keyof T]: F<T[K]> // type 'F' is not generic.
}

ただし、制限されるのは表現方法だけで、柔軟性そのものが失われるわけではない。別の方法で同じ効果を得られる。この例なら、具体的な型を当てはめたマップ型を使えばよい。

// F に具体的な型 Promise を当てはめる
type Promisify<T> = {
  [K in keyof T]: Promise<T[K]>
}

無名関数に当たるものもない。

キーワード

自分の理解

「ジェネリック型を概念的に定義する」とは、具体的な型が決まっていない段階でも、どのような型の関係性や構造を持つかをあらかじめ定めておく、ということだと思われる。