💡

【TypeScript】関数のジェネリック型について

2023/03/10に公開

はじめに

オライリーから出版されている名著「プログラミングTypeScript」を読んで、4章:関数のジェネリック型に関する部分の要約を備忘録としてまとめている。実務で使えるベストプラクティスではなく、言語仕様の理解に焦点をあてているため、ジェネリックの網羅的な理解の助けになればと思う。

一般にTypeScriptのジェネリックはジェネリクス(generics)と呼ばれることが多いが、オライリー本ではジェネリックと表記していたためこの名称を利用している。また、その他一般的に使われている用語と書籍で使われている用語に差があるため適宜一般的な用語に寄せて書いている。また、今回はインターフェースやクラスのジェネリックに関しては言及していない。

ジェネリックとは

ジェネリックは一言で述べると 「型の安全性とコードの共通化を両立するためのもの」 である。

ある関数を定義するとき、どのような型を期待すべきかが事前にわからず、関数の振る舞いを特定の型に限定したくない場合がある。そのような時に、型を引数のような振る舞いで扱い、その型の関数などを定義するときに具体的な型を渡すことができる。

具体例:与えられたarrayから関数fの条件に合うものをarrayで出力する関数のFilter型を考える
この時、オーバロードを利用して記述すると以下のようになる

オーバーロードによる型エイリアス
type Filter = {
  (array: number[], f: (item: number) => boolean): number[]
  (array: string[], f: (item: sgring) => boolean): string[]
  (array: object[], f: (item: object) => boolean): object[]
  // 入る可能性のある必要な型を全て書く...
}

これでは入る可能性のある必要な型を全て書くことになり、良いコードとは言えない。
これをジェネリック型パラメータTを使って書き直すと、filterの型は次のように書くことができる。

ジェネリックによる型エイリアス
type Filter = {
  <T>(array: T[], f: (item: T) => boolean): T[]
}

以下のコードで関数での具体的な利用例を示している。
TypeScriptは、arrayに対して渡す型からTを推論する。特定の呼び出しについて一度Tを推論すると、TypeScriptはfilterに現れる全てのTをその型で置き換える。
山括弧(<>)はジェネリック型パラメータを宣言する方法であり、Tは「ジェネリック型パラメータ」または単に「ジェネリック型」、「ジェネリック」と呼び、一般的にTの文字が使われる[1]

// 関数の定義
let filter : Filter = (array, f) => //...

// Tはnumberにバインドされる
filter([1, 2, 3], _ => _ > 2)

// Tはstringにバインドされる
filter(['a', 'b'], _ => _ !== 'b')

// Tは {firstName: string} にバインドされる
let names = [
  {firstName: 'beth'},
  {firstName: 'caitlyn'},
  {firstName: 'xin'},
]
filter(names, _ => _.firstName.startsWith('b'))

ジェネリック型の宣言方法・型がバインドされるタイミング

ジェネリック型<T>を宣言する場所によって、単にその型のスコープが決まるだけでなく、TypeScriptが具体的な型をいつジェネリックにバインドするかが決まる。

  1. 引数の'('の直前にジェネリック型<T>を定義する場合
type Filter = {
  <T>(array: T[], f: (item:T) => boolean): T[]
}
// 省略記法
type Filter = <T>(array: T[], f: (item:T) => boolean) => T[]

// Filter型の関数(filter)を呼び出すときにTをバインドする(宣言の段階ではバインドされない)
let filter: Filter = //...
  1. 型名の直後にジェネリック型<T>を定義する場合
type Filter<T> = {
  (array: T[], f: (item:T) => boolean): T[]
}
// 省略記法
type Filter<T> = (array: T[], f: (item:T) => boolean) => T[]

// 関数を宣言するときにTをバインドする
let filter: Filter<number> = //...
  1. function構文でジェネリック型<T>を定義する場合
// filterを呼び出すたびにTをバインドする
function filter<T>(array: T[], f:(item: T) => boolean): T[] {
  //...
}

複数のジェネリックを持つこともできる

map関数を例にした複数ジェネリックを持つ例
function map<T, U>(array: T[], f: (item: T) => U): U[] {
  let result = []
  for (let i = 0; i< array.length; i++){
    result[i] = f(array[i])
  }
  return result
}

ジェネリックの型推論

TypeScriptは、型をアノテートせずに関数を宣言したとしてもジェネリック型を推論することができる。
例えば、前に書いたmap関数を次のように呼び出すと、TypeScriptは、Tがstringであり、Uがbooleanであると推論する。

function map<T, U>(array: T[], f: (item: T) => U): U[] {
  //...
}

map(
  ['a', 'b', 'c'], // Tの配列: Tをstringと推論
  _ => _ === 'a'   //Uを返す関数: Uをbooleanと推論
)

ジェネリックを明示的にアノテートすることもできる。ただし、ジェネリックの明示的なアノテーションは「すべてか無か」であり、要求されるジェネリック型をすべてアノテートするかアノテートしないかのどちらかである。

map <string, boolean>(
  ['a', 'b', 'c'],
  _ => _ === 'a'
)

map <string>( //エラー TS2558: 2個の方引数が必要ですが、1個が指定されました。
  ['a', 'b', 'c'],
  _ => _ === 'a'
)

明示的にアノテーションしない場合、TypeScriptは関数の引数の型だけを使ってジェネリックの型を推論するため、引数単体で十分な情報を与えないときに型の推論ができない場合がある。

型の制約(制限付きポリモーフィズム)

ジェネリックを使う時には、「型Uは、少なくとも型T出なければならない」と言いたい場合がある。これを、「Uに上限を設ける」と言う。
二分木(バイナリーツリー)を例に実装してみる。

二分木(バイナリーツリー)とは

二分木を扱ったことがない人でも以下の基礎知識があれば十分である。

  • 二分木はデータ構造の一種である。
  • 二分木はノード(TreeNode)で構成される。
  • ノードは値(value)を保持し、最大で2つの子ノードを指し示すことができる。
  • ノードは、次の2種類の内のいずれかになることができる。
    • LeafNode(子ノードを持たないノード)
    • InnerNode(少なくとも一つの子ノードを持つノード)

二分木の実装において三種類のノードを持つ場合を考える

  1. 通常のTreeNode
  2. 子ノードを持たないTreeNodeである、LeafNode
  3. 子ノードを持つTreeNodeである、InnerNode

これらのノードについて型を宣言する

type TreeNode = {
  value: string
}

type LeafNode  = TreeNode & {
  isLeaf: true
}

type InnerNode = TreeNode & {
  children: [TreeNode] | [TreeNode, TreeNode]
}

ここでmapNode関数を実装してみる。任意のNodeと関数を引数として持ち、Nodeのvalueに対して与えられた関数を実行し、その戻り値を新しいvalueとしたNodeを返す、次のようなことを実現できるようにしたい。

let a: TreeNode  = {value: 'a'}
let b: LeafNode  = {value: 'b', isLeaf:true}
let c: InnerNode = {value: 'c', children: [b]}

// Nodeが渡されたらvalueが大文字になるようにマッピングしたい場合を考える
let a1 = mapNode(a, _ => _.toUpperCase())
let b1 = mapNode(b, _ => _.toUpperCase())
let c1 = mapNode(c, _ => _.toUpperCase())

console.log(a1)
console.log(b1)
console.log(c1)
console
[LOG]: {
  "value": "A"
} 
[LOG]: {
  "value": "B",
  "isLeaf": true
} 
[LOG]: {
  "value": "C",
  "children": [
    {
      "value": "b",
      "isLeaf": true
    }
  ]
} 

ここでmapNode関数を型の観点でどのように実装するかを考える。
mapNode関数はTreeNodeのサブタイプ(LeafNodeまたはInnerNode)を取り、返す型もそれと同じ型を返す。つまり、LeafNodeを渡すとLeafNodeを返し、InnerNodeを渡すとInnerNodeを返し、TreeNodeを渡すとTreeNodeを返す。
また、渡される型は、少なくともTreeNode型の要素(value)を持つ型である必要がある
これを表現した関数は以下のように書くことができる。

function mapNode<T extends TreeNode>( // 1 
  node: T,
  f: (value: string) => string
): T {
  return {
    ...node,
    value: f(node.value) // 2
    }
}
  1. ジェネリック型TはTreeNodeのサブタイプであることを制限する。
  2. nodeはTreeNodeであるため、必ずvalueを要素にもつ。そのためnode.valueで常にアクセス可能である。
    引数がTreeNode型であるという制約がないとnode.valueにアクセスできないためエラーになる。
extends TreeNodeの制約がない場合エラーになる
function mapNode<T>( // extends TreeNodeを削除してみる
  node: T,
  f: (value: string) => string
): T {
  return {
     ...node,
    value: f(node.value) // エラー: Property 'value' does not exist on type 'T'.(2339)
  }
}

複数の型制約

複数の型制約を持つこともできる。その場合、単に交差(&)で型どうしを繋げば良い。

numberOfSideは辺の数、sideLengthは辺の長さを表し、logPerimeter関数は外周の長さをコンソールに表示しその値を返す。
ジェネリック型Shapeは少なくともHasSides型とSidesHaveLength型の要素を持つ

type HasSides        = {numberOfSides: number}
type SidesHaveLength = {sideLength: number}

function logPreimeter<Shape extends HasSides & SidesHaveLength>(s: Shape): Shape {
  console.log(s.numberOfSides * s.sideLength)
  return s
}

type Square = HasSides & SidesHaveLength
let square: Square = {numberOfSides: 4, sideLength: 3}
logPreimeter(square) // 辺の数4、長さ3の図形の外周の長さ。12が出力される

ジェネリック型のデフォルトの型

関数の引数にデフォルトの値を指定できるのと同様に、ジェネリック型パラメータにデフォルトの型を宣言することができる。

type MyEvent<T = HTMLElement> = {
  target: T
  type: string
}

この機会に前のいくつかの節で学んだことを応用し、Tに制限を追加して、Tが必ずHTML要素であるように変更してみる。

type MyEvent<T extends HTMLELement = HTMLElement> = {
  target: T
  type: string
}

これにより特定のHTML要素型に固有でないイベントを容易に作成することができ、そのイベントを作成するときに、MyEventのTをHTMLElementに明示的に手動でバインドする必要がなくなった。

let myEvent: MyEvent = {
  target: myElement
  type: string
}

参考文献

https://www.ohmsha.co.jp/book/9784873119045/

https://typescriptbook.jp/reference/generics

脚注
  1. ジェネリック型は慣例的にTが使われ、必要なジェネリックの数に応じてU、V、Wをとることが多い。しかし、多くのジェネリックを続けて宣言している場合や、複雑な方法でそれらを使っている場合は、この慣習から離れて、代わりにValueやWidgetTypeのような、より説明的な名前を使うことも検討すべきである。 ↩︎

Discussion