12
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【TypeScript】オーバーロードを使用する上での注意点

Posted at

はじめに

一昨日、TypeScript のオーバーロードの様々な書き方についてまとめてみました。
本日は、オーバーロードにおける注意点・ハマりどころについて、まとめてみたいと思います。

※主に、TypeScript-Handbook = Do's and Don'ts.mdを私なりに噛み砕いたものになります。

環境

TypeScript: v4.1.3

コード

Playground Link

注意点・ハマりどころ

オーバーロードの型定義は実装を考慮しない

オーバーロードのために定義された型は、あくまで関数の引数と返り値の組み合わせを定義しただけです。
また、オーバーロードされた関数は、定義された型をすべて満たしていればエラーになりません。

以下に簡単な例を示します。
型定義上は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) => {});

参考: Overloads and Callbacks

より一般的なオーバーロードより、より具体的なオーバーロードを先に定義する

関数呼び出し時に、引数が複数のオーバーロードにマッチする場合、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

参考: Use Optional Parameters

オーバーロードの引数の型のみが異なる場合、オーバーロードではなく 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 がオーバーロードを実装したせいか、挙動が不安定に感じます。
よほど処理が似通ってない限り、素直にメソッドを分割するのが良いと思います。

参考

12
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?