🏦

【TypeScript】ジェネリックスを徹底解説する記事

2023/03/03に公開

はじめに

今回の記事では、TypeScriptの中でも特に理解に苦しむ文法であるジェネリックスを徹底解説する。今回の記事が、TypeScriptにおけるジェネリックスへの理解を深めるきっかけになれば非常に幸いだ。

ジェネリックス(Generics)とは

TypeScriptにおけるジェネリックス(Generics)とは、型の安全性とコードの共通化を両立させるために導入された言語の仕様である。これを用いることで、型の安全性とコードの共通化を両立できるのだ。

解説

ジェネリックスが具体的にどのように使われているのかを見ていこう。以下のプログラムで説明する。

function sampleLottery(v1: string, v2: string): string {
     return Math.random() <= 0.5 ? v1 : v2 
}

上述はsampleLottery()関数で、50%の確率で第一引数あるいは第二引数の値をランダムで返す。この関数は、文字列の抽選に限って以下のように再利用できる。

const lottery1 = sampleLottery("あたり", "はずれ")

次に、文字列だけではなく数値のランダムな抽出も同じ関数を使って実装する場合を想定する。sampleLottery()関数は文字列にしか対応していないので、数値を入れるための新しい関数を別途で用意しなければならない。

function sampleLottery(v1: number, v2: number): number {
     return Math.random() <= 0.5 ? v1 : v2 
}
const num: number = sampleLottery(1, 3)

さらに、sampleLottery()関数のロジックを応用して、Personオブジェクト向けの実装を作ることにもなった。

function sampleLottery(v1: Person, v2: Person): Person {
     return Math.random() <= 0.5 ? v1 : v2 
}
const num: number = sampleLottery(person1, person2)

このように考えると、引数の型が異なるsampleLottery()関数が3つも複製されることになり面倒である。

// NOTE: 引数のデータ型が異なる3つの同じ関数が出力される。できるだけ重複したコードは1つに統一したい...
function sampleLottery(v1: string, v2: string): string {
     return Math.random() <= 0.5 ? v1 : v2 
}

function sampleLottery(v1: number, v2: number): number {
     return Math.random() <= 0.5 ? v1 : v2 
}

function sampleLottery(v1: Person, v2: Person): Person {
     return Math.random() <= 0.5 ? v1 : v2 
}

引数をまとめて、1つの関数でこれらを表現するにはどうすればいいのだろうか?

考えられる方法としては、データ型をanyにすることが考えられる。しかし、この方法には致命的な欠陥がある。それは、戻り値の型もanyとして出力されるのでコンパイラがチェックしないことだ。そのため、バグを生みやすく型の安全性が損なわれてしまう。

// 実行するとバグが発生するサンプルコード
function sampleLottery(v1: any, v2: any): any {
     return Math.random() <= 0.5 ? v1 : v2 
}

const str = sampleLottery(1, 2)
str = str.toLowerCase() // この行でTypeErrorが出力されてしまう

上述は、sampleLottery()関数にnumber型を渡しているものの、戻り値はstring型として扱っている。このコードはコンパイルエラーにならないものの、コンパイル後のエラーを出力すると5行目でTypeError: str.toLowerCase is not a functionというエラーが出力されてしまう。

ここで、コードの共通化と型の安全性の両方を達成するためにジェネリックスが採用される。ジェネリックスの発想は非常にシンプルで、型も変数のように扱えるようにすることだ。汎用的なコードを多種多様な型で使えるようにするためにジェネリックスが使われている。上述のsampleLottery()関数をジェネリックスを使って書き直すと以下のようになる。

// NOTE: 同じ引数を使うので、型変数の名前をTと表記。
// このように書くことで、似たような関数を重複して書くことを防いでいる。
function sampleLottery<T>(v1: T, v2: T): T {
  return Math.random() <= 0.5 ? v1 : v2 
}
sampleLottery<string>('あたり', 'はずれ')
sampleLottery<number>(1, 2)
sampleLottery<Person>(person1, person2)

前述の、実行するとバグが発生するサンプルコードに、ジェネリックス化したsampleLottery()関数を使って書いてみよう。

function sampleLottery<T>(v1: T, v2: T): T {
     return Math.random() <= 0.5 ? v1 : v2 
}

// この変数宣言文にコンパイルエラーが出力される。
const str = sampleLottery<string>(1, 2)

str = str.toLowerCase()

上述のサンプルコードの次の変数宣言文である、

const str = sampleLottery<string>(1, 2)

こちらにArgument of type '1' is not assignable to parameter of type 'string'.というように出力されるはずだ。このようにジェネリックスを使って関数を書くと、string型を入れなければならないところに1を代入しているバグに気づくことができた。

このようにして、ジェネリックスはコードの共通化と型の安全性を両立してくれる言語機能である。コードを使い回すときに、多種多様な型で使えるようにしたい場合はジェネリックスを検討してみるといいだろう。

主なサンプルコード

複数のデータ型の値を出力する

function printData<A, B>(data1: A, data2: B) {
      console.log(`出力したデータ:{$data1}、{$data2}`)
}

printData('Hello', 'World')
printData(100, ['hello', 100])

インターフェイスとジェネリックスを融合させる

TypeScriptのジェネリックスはインターフェイスと組み合わせて使うことができる。以下のサンプルを考えてみよう。

// 型を変数として扱うことで、どのような型にも対応できるインターフェイスを作る
interface UserData<X,Y> {
    name: X 
    personNo: Y 
}

const user: UserData<string, number> = {
    name: "Shota", // nameはstring型
    personNo: 1 // personNoはnumber型
}

上の例では、<string, number>UserDataというインターフェイスに渡されている。このように、UserDataはユースケースに応じて任意のデータ型を割り当てることができる、再利用可能なインターフェイスとなる。

クラス

// Queueクラスで作成されるリストに<T>と表記することで、格納できるデータ型を指定できる。
class Queue<T> {
  private data = [] 
  push(item: T) { this.data.push(item) }
  pop(): T | undefined { return this.data.shift() }
}

// この変数宣言文のように、予め格納するデータ型を指定できる。
const queue = new Queue<number>() 
queue.push(0) 

おわりに

  • コードを共通化すると、型の安全性が弱まる。型の安全性を高めると、コードの共通化が難しくなる。このようなトレードオフの関係ではなく、両者を両立させるためにジェネリックスという言語仕様がある。
  • ジェネリックスは、コードの共通化と型の安全性を両立させるための言語仕様。
  • ジェネリックスを一言で述べると、型を引数のように扱うという発想をベースにつくられた仕様。

参考サイト

GitHubで編集を提案

Discussion