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)

TypeScript の「オーバーロード」について

オーバーロードとは、関数に対して複数の型を定義すること。
複数の型を持つ関数を「オーバーロードされた関数」と呼んだりする。

オーバーロードによって、「渡された引数の数によって各パラメータの型が変わる関数」や「パラメータの型によって返り値の型が変わる関数」を表現できる。

以下の内容は、TypeScript のv3.9.2で動作確認している。

渡された引数の数によって各パラメータの型が変わる関数

まず、「渡された引数の数によって各パラメータの型が変わる関数」について。
具体的な型を例示すると分かりやすいので、早速、定義してみる。

type HasFoo = {
  foo: number;
};
type HasBar = {
  bar: string;
};

type ReturnFooAndBar = {
  (arg1: number): HasFoo & HasBar;
  (arg1: string, arg2: number): HasFoo & HasBar;
};

ReturnFooAndBarによって表現される関数は、引数をいくつ渡すかによって、arg1の型が変わる。
ひとつの場合はnumber、ふたつの場合はstringになる。
返り値の型は固定で、どちらの場合もHasFoo & HasBarを返す。

このようにtype 型の名前 = {関数の型1; 関数の型2; ...}と書くことで、ひとつの関数に対して複数の型を定義できる。

次に、オーバーロードされた関数を実装する方法だが、関数宣言と関数式で異なる。

関数宣言

関数宣言の場合、以下のように同じ名前の関数を複数宣言する。

function 関数名 関数の型1
function 関数名 関数の型2
function 関数名 両方の型を満たす実装

ReturnFooAndBarの場合は以下のようになる。

function returnFooAndBar(arg1: number): HasFoo & HasBar;
function returnFooAndBar(arg1: string, arg2: number): HasFoo & HasBar;
function returnFooAndBar(arg1: number | string, arg2?: number) {
  if (typeof arg1 === "number") {
    return {
      foo: arg1,
      bar: "a",
    };
  }
  return {
    foo: arg2,
    bar: arg1,
  };
}

const x: ReturnFooAndBar = returnFooAndBar; // ok

returnFooAndBar(9); //ok
returnFooAndBar("z", 9); // ok
returnFooAndBar(9, 8); // Argument of type '9' is not assignable to parameter of type 'string'.ts(2345)
returnFooAndBar("z"); // Argument of type '"z"' is not assignable to parameter of type 'number'.ts(2345)

3 番目のreturnFooAndBarの宣言で具体的な実装を行っているが、これは、2 つの型の両方を満たすものになっていなければならない。

例えばパラメータは(arg1: number | string, arg2?: number)になっている。
これは、ひとつめのパラメータはnumberstring両方の可能性があり、ふたつめのパラメータは渡されない可能性があるため、このような記述になっている。

最後に実際にreturnFooAndBarを使っているが、returnFooAndBar(number)returnFooAndBar(string, number)以外はエラーになっていることを確認できる。

ただ、この記法だと、TypeScript による型チェックが上手く機能しなかった。
例えばreturnFooAndBarが空のオブジェクトを返しても、エラーにならない。
その結果、空のオブジェクトであるyfoobarというプロパティを持っていることになってしまっている。

function returnFooAndBar(arg1: number): HasFoo & HasBar;
function returnFooAndBar(arg1: string, arg2: number): HasFoo & HasBar;
function returnFooAndBar(arg1: number | string, arg2?: number) {
  return {};
}

const x: ReturnFooAndBar = returnFooAndBar; // ok

const y = returnFooAndBar(1); // const y: HasFoo & HasBar
y.foo; // (property) foo: number
y.bar; // (property) bar: string

// 実際には foo も bar も undefined
console.log(y.foo, y.bar); // undefined undefined

関数式

関数式の場合、関数宣言とはまた違った点に注意しないといけない。

以下のように、絶対に実行されないコードを書く必要があった。
arg1arg2のどちらかはnumberなので最後のreturnまでコードが到達することはないのだが、これを書かないと「この関数はundefinedを返す可能性がある」と見做され、ReturnFooAndBarを割り当てることができない。

const returnFooAndBar: ReturnFooAndBar = (
  arg1: number | string,
  arg2?: number,
) => {
  if (typeof arg1 === "number") {
    return {
      foo: arg1,
      bar: "a",
    };
  } else if (typeof arg2 === "number") {
    return {
      foo: arg2,
      bar: arg1,
    };
  }
  // 以下のコードはどのようなケースでも実行されないが、これがないと TypeScript がエラーを出す
  return {
    foo: 1,
    bar: arg1,
  };
};

returnFooAndBar(9); //ok
returnFooAndBar("z", 9); // ok
returnFooAndBar(9, 8); // Argument of type '9' is not assignable to parameter of type 'string'.ts(2345)
returnFooAndBar("z"); // Argument of type '"z"' is not assignable to parameter of type 'number'.ts(2345)

関数宣言とは異なり、ReturnFooAndBarに一致しない実装をすると、エラーを出してくれる。

const returnFooAndBar: ReturnFooAndBar = (
  arg1: number | string,
  arg2?: number,
) => {
  if (typeof arg1 === "number") {
    return {
      foo: arg1,
      bar: 1, // string であるべきなのに number になっているので、エラーになる
    };
  } else if (typeof arg2 === "number") {
    return {
      foo: arg2,
      bar: arg1,
    };
  }
  // 以下のコードはどのようなケースでも実行されないが、これがないと TypeScript がエラーを出す
  return {
    foo: 1,
    bar: arg1,
  };
};

パラメータの型によって返り値の型が変わる関数

次は、パラメータの型によって返り値の型が変わる関数を、オーバーロードで表現する。

型エイリアスで表現する方法は、先程と変わらない。

type HasFoo = {
  foo: number;
};
type HasBar = {
  bar: string;
};

type ReturnFooOrBar = {
  (arg: number): HasFoo;
  (arg: string): HasBar;
};

ReturnFooOrBarは、numberを渡されたらHasFooを、stringを渡されたらHasBarを返す。

関数宣言

関数宣言で実装する方法も、先程と変わらない。まずは型を宣言し、最後に両方の型を満たす実装を書く。

function returnFooOrBar(arg: number): HasFoo;
function returnFooOrBar(arg: string): HasBar;
function returnFooOrBar(arg: string | number) {
  if (typeof arg === "number") {
    return {
      foo: arg,
    };
  }
  return {
    bar: arg,
  };
}

const x: ReturnFooOrBar = returnFooOrBar; // ok

const y = returnFooOrBar(3); // const y: HasFoo
const z = returnFooOrBar("e"); // const z: HasBar

console.log(y); // { foo: 3 }
console.log(z); // { bar: "e" }

空のオブジェクトを返してもエラーが出ないのも、先程と同じ。

function returnFooOrBar(arg: number): HasFoo;
function returnFooOrBar(arg: string): HasBar;
function returnFooOrBar(arg: string | number) {
  return {};
}

const x: ReturnFooOrBar = returnFooOrBar; // ok

const y = returnFooOrBar(3); // const y: HasFoo
const z = returnFooOrBar("e"); // const z: HasBar

console.log(y); // {}
console.log(z); // {}

関数式

関数式ではそもそも、returnFooOrBarを定義できなかった。
例えば以下のreturnFooOrBarの実装は、先程の関数宣言のものと同じなのだが、ReturnFooOrBarにはならない。
返り値の型が{foo: number;} | {bar: string;}になってしまっており、パラメータの型によって返り値の型を特定する、ということが出来ていない。

const returnFooOrBar = (arg: number | string) => {
  if (typeof arg === "number") {
    const result = {
      foo: arg,
    };
    return result;
  }
  const result = {
    bar: arg,
  };
  return result;
};

const x: ReturnFooOrBar = returnFooOrBar; // error

const y = returnFooOrBar(3); // const y: {foo: number;} | {bar: string;}
const z = returnFooOrBar("e"); // const z: {foo: number;} | {bar: string;}

console.log(y); // { foo: 3 }
console.log(z); // { bar: "e" }