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

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

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" }