はじめに
一昨日、TypeScript のオーバーロードの様々な書き方についてまとめてみました。
本日は、オーバーロードにおける注意点・ハマりどころについて、まとめてみたいと思います。
※主に、TypeScript-Handbook = Do's and Don'ts.mdを私なりに噛み砕いたものになります。
環境
TypeScript: v4.1.3
コード
注意点・ハマりどころ
オーバーロードの型定義は実装を考慮しない
オーバーロードのために定義された型は、あくまで関数の引数と返り値の組み合わせを定義しただけです。
また、オーバーロードされた関数は、定義された型をすべて満たしていればエラーになりません。
以下に簡単な例を示します。
型定義上はnumber
の引数を受け取ったらstring
の値を返し、string
の引数を受け取ったらnumber
の値を返すようになっています。
しかし、実装上は受け取った値をそのまま返却する関数です。
function fn(val: number): string;
function fn(val: string): number;
function fn(val: number | string) {
return val;
}
// const num: string !?
const num = fn(1);
// const str: number !?
const str = fn("1");
残念ながら、これはエラーになりません。
というのも、関数fn
の型定義はfn(val: number | string): string | number
となるため、オーバーロードの 2 つの型定義を全て満たしている扱いになるからです。
そのため、返り値の異なるオーバーロードを実装する際は、型推論を過信しないことが大事です。
そして、可能であればオーバーロードを使わずに関数を分けることが望ましいです。
コールバックの引数の数だけが異なるオーバーロードは定義する必要がない
通常の関数で、引数の数が異なる同名の関数を定義したい場合は、オーバーロードする必要があります。
interface T {
(arg1: string): void;
(arg1: string, arg2: string, arg3: string): void;
}
const fn: T = (arg1: string, arg2?: string, arg3?: string) => {};
しかし、コールバック関数においては、引数の数だけが異なる場合はオーバーロードする必要がありません。
コールバック関数に渡される引数を、コールバック関数側で無視することは、JavaScript においては問題ないからです。
Array.prototype.forEach
の型を例にとって解説します。
Array | typescript - v3.7.5に示されるように、foreach
ではコールバック関数に、value
, index
, array
の 3 つの引数を渡すように実装されています。
interface ReadonlyArray<T> {
/**
* Performs the specified action for each element in an array.
* @param callbackfn A function that accepts up to three arguments. forEach calls the callbackfn function one time for each element in the array.
* @param thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
*/
forEach(
callbackfn: (value: T, index: number, array: readonly T[]) => void, // <- here
thisArg?: any
): void;
}
上の型定義の通り、foreach
の引数のコールバック関数はオーバーロードされていません。
しかし、以下のようにコールバック関数は任意の引数を受け取ることができます。
[1, 2, 3].forEach(() => {});
[1, 2, 3].forEach((val) => {});
[1, 2, 3].forEach((val, index) => {});
[1, 2, 3].forEach((val, index, array) => {});
より一般的なオーバーロードより、より具体的なオーバーロードを先に定義する
関数呼び出し時に、引数が複数のオーバーロードにマッチする場合、TypeScript は最初にマッチするオーバーロードを採用します。
そのため、より一般的なオーバーロードを先に定義してしまうと、後に定義したオーバーロードが呼び出されなくなります。
function fn(val: any): any;
function fn(val: number): number;
function fn(val: string): string;
function fn(val: number | string) {
return val;
}
// const one: any
const one = fn(1);
// const str: string
const str = fn("1");
function fn(val: number): number;
function fn(val: string): string;
function fn(val: any): any;
function fn(val: number | string) {
return val;
}
// const one: number
const one = fn(1);
// const str: string
const str = fn("1");
参考: Ordering
末尾の引数のみが異なるオーバーロードを定義したい場合は、任意引数を使用する
以下のように、末尾の引数のみが異なるオーバーロードの場合、オーバーロードを使用するのではなく任意引数を使用した方がよいです。
// 誤
interface T {
(one: string): void;
(one: string, two: string): void;
}
// 正
interface T {
(one: string, two?: string): void;
}
本件の本題に入る前に、先程触れたコールバック関数の型定義について、以下の点を抑えておきます。
コールバック関数に渡される引数を、コールバック関数側で無視することは、JavaScript においては問題無く、TypeScriptでも型エラーが発生しない
つまり、以下のような挙動になります。
const fn = (f: (a: string, b: number) => void) => {};
// fnのコールバック関数の型にマッチしない(引数の数が少ない)
const callback = (one: string) => {};
// fnのコールバック関数の型にマッチする
const callback2 = (one: string, two: number) => {};
// どちらもエラーにならない
fn(callback); // No Error
fn(callback2); // No Error
上記の挙動とオーバーロードの挙動が組み合わさることで、以下のようなバグを埋め込まれる可能性があります。
以下の場合、2 番目のオーバーロードがコールバック関数の型定義を満たしていません。
しかし、1 番目のオーバーロードが満たしているため、エラーが表示されません。
const fn = (f: (a: string, b: number) => void) => {};
interface T {
(one: string): void;
// 上と異なり、twoの型をstringに変更 == fnの引数の型を満たさない
(one: string, two: string): void;
}
const callback3: T = (one: string, two?: string) => {
// two は `string | undefined` を期待する
//しかし、関数fnのコールバック関数として定義された場合は `number` となってしまう
};
// 最初のオーバーロード `(one: string): void;` が引数の型を満たす
// そのため、2番目以降の型が不正であっても無視される
fn(callback3); // No Error
これを回避するには、オーバーロードではなく任意引数を使用する必要があります。
任意引数を使用した場合、以下のようにエラーが発生してくれます。
const fn = (f: (a: string, b: number) => void) => {};
interface T {
(one: string, two?: string): void;
}
const callback3: T = (one: string, two?: string) => {};
// Argument of type 'T' is not assignable to parameter of type '(a: string, b: number) => void'.
// Types of parameters 'two' and 'b' are incompatible.
// Type 'number' is not assignable to type 'string | undefined'.
fn(callback3); // Error
オーバーロードの引数の型のみが異なる場合、オーバーロードではなく union type を使用した方が良い
以下のようにオーバーロードを定義した場合、引数にstring
, number
を渡すことはできるが、
string | number
を渡すことはできません。
interface T {
(): string;
(val: number): void;
(val: string): void;
}
const fn: T = (val?: number | string): any => {};
fn(1); // No Error
fn(""); // No Error
const _ = (x: number | string) => {
// No overload matches this call.
// Overload 1 of 3, '(val: number): void', gave the following error.
// Argument of type 'string | number' is not assignable to parameter of type 'number'.
// Type 'string' is not assignable to type 'number'.
// Overload 2 of 3, '(val: string): void', gave the following error.
// Argument of type 'string | number' is not assignable to parameter of type 'string'.
// Type 'number' is not assignable to type 'string'.
fn(x);
};
これが union type であれば、string | number
でも引数に渡すことができます。
interface T {
(): string;
(val: number | string): void;
}
const fn: T = (val?: number | string): any => {};
fn(1); // No Error
fn(""); // No Error
const _ = (x: number | string) => {
fn(x); // No Error
};
参考: Use Union Types
返り値の異なるオーバーロードを定義する場合、返り値の型をany
にする必要がある
以下のように返り値の異なるオーバーロードを定義する場合、実装関数の返り値の型が union type (number | string
)だと、Type 'string' is not assignable to type 'number'
というエラーが表示されます。
type T = {
(val: number): number;
(val: string): string;
};
// Type 'string' is not assignable to type 'number'
const fn: T = (val: number | string) => val;
そのため、実装関数の返り値の型を any にする必要があります。
// No Error
const fn: T = (val: number | string): any => val;
// const num: number
const num = fn(1);
// const str: string
const str = fn("1");
詳細は、以下の記事を参照していただけると嬉しいです。
【TypeScript】返り値の異なるオーバーロードを定義する場合、返り値の型をany
にする必要がある
所感
元々オーバーロードの存在しない JavaScript に対して TypeScript がオーバーロードを実装したせいか、挙動が不安定に感じます。
よほど処理が似通ってない限り、素直にメソッドを分割するのが良いと思います。