30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

TypeScript のジェネリック型の初歩

ジェネリック型は、型を定義したときには具体的な型を指定せず、型を利用する際に具体的な型が決まる仕組み。
後述するが、プレースホルダや関数のパラメータのようなものだと考えると、分かりやすい。
ジェネリック型はそれ自体が便利だし、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; // ?

コメントに書いたように、Foonumberを、Barstringを受け取る。そして受け取ったものと同じ型を返す。
Bazは、FooBarとは異なる見た目をしている。関数の型の先頭に<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を渡すとT1に、"a"を渡すとT"a"になる。

この仕組みは、プレースホルダに似ている。
TypeScript は、渡された値からTが何であるかを推論し、推論された型をTにバインドする。

あるいは、関数のパラメータのようなものとも、言えるかもしれない。

const foo = (arg) => {
  console.log(arg);
  return arg;
}

上記の関数fooは、パラメータをログに表示し、そしてそれをそのまま返すだけの関数である。
そしてこの時点では、パラメータargにどんな値が来るかは分からない。この関数を呼び出したときに初めて決まる。
foo(1)とするとfoo内の全てのarg1で置き換え、foo("a")とするとfoo内の全てのarg"a"で置き換える。

ジェネリック型も、そのように考えると分かりやすい。
実際に型を利用する度に、Tを具体的な型に置き換える。

なお、ここまでTという名前を使ってきたが、実際にはTである必要はないし、一文字である必要もない。
慣例として、typeの頭文字であるTを使う事が多い。その場合、複数のジェネリック型を使う際はアルファベット順にTUV、という要領で増やしていく。

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内のTstringに置き換えたものが、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 クラス名<ジェネリック型>という形式で記述する。

以下のProductTは、例のごとく定義時には型が決まっていない。

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においては、Tstringでバインドされているため、setDescription1を渡すとエラーになる(1stringに割り当て不可であるため)。

a.setDescription(1); // Argument of type '1' is not assignable to parameter of type 'string'.ts(2345)

TypeScript に推論させるのではなく、インスタンス作成時に型を明示的に指定することもできる。
以下のコードでは、インスタンス作成時に明示的にTStatusをバインドしている。

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)