ジェネリック型は、型を定義したときには具体的な型を指定せず、型を利用する際に具体的な型が決まる仕組み。
後述するが、プレースホルダや関数のパラメータのようなものだと考えると、分かりやすい。
ジェネリック型はそれ自体が便利だし、TypeScript の他の機能と組み合わせることで複雑な型も表現できるようになる。
この記事の内容は TypeScript のv3.9.5
で動作確認している。
宣言時には型が決まらず、あとから具体的な型がバインドされる
以下に、関数の型を 3 つ定義している。
type Foo = (arg: number) => number; // number を受け取り、number を返す type Bar = (arg: string) => string; // string を受け取り、string を返す type Baz = <T>(arg: T) => T; // ?
コメントに書いたように、Foo
はnumber
を、Bar
はstring
を受け取る。そして受け取ったものと同じ型を返す。
Baz
は、Foo
やBar
とは異なる見た目をしている。関数の型の先頭に<T>
がついており、パラメータや返り値の型としてT
を定義している。これが、ジェネリック型である。
では、このT
は、具体的にどんな型を意味しているのか。Baz
はどんな型を受け取り、返り値の型はどんなものになるのだろうか?
答えは、「この時点では決まっていない」である。
ジェネリック型は、定義した時点ではそれが具体的にどんな型なのか、決まらない。
分かっているのは、パラメータと返り値は同じ型(T
)である、ということだけ。
後述するextends
キーワードを使うことで条件付けを行うことはできるが、T
が何なのか決まるのは、ジェネリック型を含む型(この場合はBaz
)を使用するときである。
以下で、Baz
型を満たすbaz
を実装している。
そしてこの関数が実際に使用されるタイミングで、T
の具体的な型が決まる。
type Baz = <T>(arg: T) => T; const baz: Baz = (arg) => arg; baz(1); // const baz: <1>(arg: 1) => 1 baz("a"); // const baz: <"a">(arg: "a") => "a"
引数として1
を渡すとT
が1
に、"a"
を渡すとT
が"a"
になる。
この仕組みは、プレースホルダに似ている。
TypeScript は、渡された値からT
が何であるかを推論し、推論された型をT
にバインドする。
あるいは、関数のパラメータのようなものとも、言えるかもしれない。
const foo = (arg) => { console.log(arg); return arg; }
上記の関数foo
は、パラメータをログに表示し、そしてそれをそのまま返すだけの関数である。
そしてこの時点では、パラメータarg
にどんな値が来るかは分からない。この関数を呼び出したときに初めて決まる。
foo(1)
とするとfoo
内の全てのarg
を1
で置き換え、foo("a")
とするとfoo
内の全てのarg
を"a"
で置き換える。
ジェネリック型も、そのように考えると分かりやすい。
実際に型を利用する度に、T
を具体的な型に置き換える。
なお、ここまでT
という名前を使ってきたが、実際にはT
である必要はないし、一文字である必要もない。
慣例として、type
の頭文字であるT
を使う事が多い。その場合、複数のジェネリック型を使う際はアルファベット順にT
、U
、V
、という要領で増やしていく。
TypeScript が推論した結果、どうやっても矛盾なくバインドできない、整合性を保てない、という場合はエラーになる。
例えば、以下のMerge
型とその実装merge
は、第一引数はT
の配列、第二引数はT
でなければならない。
type Merge = <T>(array: T[], item: T) => T[]; const merge: Merge = (array, item) => [...array, item];
以下は、問題ない。第一引数がnumber[]
であり第二引数がnumber
なので、(array: T[], item: T)
という条件を満たしている。
merge([3, 4], 5); // const merge: <number>(array: number[], item: number) => number[]
だが以下はその条件を満たしていないため、TypeScript はエラーを出す。
merge([3, 4], "a"); // Argument of type '"a"' is not assignable to parameter of type 'number'.ts(2345)
明示的なアノテート
TypeScript の推論に任せず、明示的にアノテートすることもできる。
関数の場合、関数名<アノテート>(引数)
と書けばよい。
以下では、明示的にnumber
をアノテートしている。
type Baz = <T>(arg: T) => T; const baz: Baz = (arg) => arg; baz(1); // const baz: <1>(arg: 1) => 1 baz<number>(1); // const baz: <number>(arg: number) => number
この場合、T
には1
ではなくnumber
がバインドされる。
そのため、baz
の型は<number>(arg: number) => number
になる。
この関数に対してnumber
のサブタイプである1
を渡しているため、この使い方には何の問題もない。
だが以下のコードは、"a"
を渡してしまっており、それをnumber
に割り当てることはできないため、エラーになる。
baz<number>("a"); // Argument of type '"a"' is not assignable to parameter of type 'number'.ts(2345)
型エイリアスに対してジェネリック型を設定する
ここまで、関数に対してジェネリック型を使ってきたが、関数以外にも使うことができる。
型エイリアスに対して使うときは、以下のように書く。
type History<T> = { past: T[]; present: T; future: T[]; };
この時点ではT
の具体的な型は決まっておらず、History
を使う際に明示的に指定する。
以下の例では、History
内のT
をstring
に置き換えたものが、urlHistory
の型になる。
type History<T> = { past: T[]; present: T; future: T[]; }; const urlHistory: History<string> = { past: ["/", "/product"], present: "/product/a", future: ["/product/a/detail"], };
デフォルト値を設定することもできる。
アノテートが省略可能になり、省略した場合はデフォルト値がT
にバインドされる。
type History<T = string> = { past: T[]; present: T; future: T[]; }; // type A = { // past: string[]; // present: string; // future: string[]; // } type A = History; // type B = { // past: number[]; // present: number; // future: number[]; // } type B = History<number>;
型エイリアスで設定されたジェネリック型は、その型エイリアスの中で自由に使うことができる。
type Foo<T> = { func: (arg: T) => T; prop: T; };
func
でもprop
でもT
を使うことができている。
これに対して、関数に設定されたジェネリック型は、その関数の中でしか使えない。
以下の例ではFoo
ではなくfunc
に対してT
を使っているため、prop
ではT
を使うことができない。
type Foo = { func: <T>(arg: T) => T; prop: T; // Cannot find name 'T'.ts(2304) };
クラスに対してジェネリック型を設定する
クラスに対してもジェネリック型を使用できる。
class クラス名<ジェネリック型>
という形式で記述する。
以下のProduct
のT
は、例のごとく定義時には型が決まっていない。
class Product<T> { description: T; constructor(description: T) { this.description = description; } getDescription() { // 返り値は T であると推論される return this.description; } setDescription(newValue: T) { this.description = newValue; } }
new
でインスタンスを作成した際に、その引数によって型が推論されて、T
にバインドされる。
const a = new Product("abc"); // const a: Product<string> const x = a.getDescription(); // const x: string console.log(x); // abc
インスタンスa
においては、T
はstring
でバインドされているため、setDescription
に1
を渡すとエラーになる(1
はstring
に割り当て不可であるため)。
a.setDescription(1); // Argument of type '1' is not assignable to parameter of type 'string'.ts(2345)
TypeScript に推論させるのではなく、インスタンス作成時に型を明示的に指定することもできる。
以下のコードでは、インスタンス作成時に明示的にT
にStatus
をバインドしている。
type Status = "complete" | "processing" | "pending"; const b = new Product<Status>("complete"); // const b: Product<Status> const y = b.getDescription(); // const y: Status
もちろんこの場合も、整合性が取れない場合はエラーになる。
const c = new Product<Status>("foo"); // Argument of type '"foo"' is not assignable to parameter of type 'Status'.ts(2345)
extends キーワードによる制約
ここまでの例では全て、整合性が保たれている限り、T
には自由な型をバインドできた。
extends
キーワードを使うことで、バインドできる型に制約を与えることができる。
ジェネリック型を定義する際に<T extends 型>
と記述すると、T
には、型か、そのサブタイプしかバインドできなくなる。
以下のコードではT
に対してnumber | string
という制約を与えている。
そのためT
には、number | string
か、そのサブタイプしかバインドできない。
type Foo = <T extends number | string>(arg: T) => T;
1
や"a"
はnumber | string
のサブタイプなのでT
にバインドできるが、true
はそうではないので、エラーになる。
type Foo = <T extends number | string>(arg: T) => T; const foo: Foo = (arg) => arg; foo(1); // const foo: <1>(arg: 1) => 1 foo("a"); // const foo: <"a">(arg: "a") => "a" foo(true); // Argument of type 'true' is not assignable to parameter of type 'string | number'.ts(2345)