atama plus techblog
🦔

TypeScriptのオーバーロードを用いて同期関数か非同期関数かを切り替える

2024/08/06に公開

やりたかったこと

TypeScriptにおいて、1つの関数が同期関数として振る舞うか非同期関数として振る舞うかを動的に切り替えたい状況がありました。

例えばある関数について、基本的には同期関数として振る舞うが、引数の async オプションを渡したときは非同期関数にする、といった具合です。

// 通常はstringを返す
exampleFunction() // => string

// asyncオプションをオンにしたときだけPromise<string>を返したい
exampleFunction(async: true) // => Promise<string>

なぜ1つの関数で同期と非同期を切り替えたかったのか

以下のように非同期関数を受け取り、その非同期処理を実行してPromiseを返す関数がありました。

const applyFunc = async (asyncFunc: () => Promise<void>) => {
  // ロジック
  // ...
  await asyncFunc();
}

この関数に同期関数を渡した時は非同期ではなく同期的に処理を実行するよう変更したかったです。

これを実現するには、大きく分けて以下の2パターンがあると思います。

  1. 1つの関数で同期処理と非同期処理を上手く扱えるようにする
  2. 関数を同期処理版と非同期処理版の2つを作成する

パターン2では、同じような関数を2つ作ることになるため、以下の問題があります。

  • 振る舞いを変えたいときに両方に変更を入れる必要がある。どちらかに変更を入れ忘れた際バグにつながる可能性がある。
  • 2重で実装されていることを知らない開発者がコードを追う際に認知負荷が上がる

そのため、1つの関数で同期処理と非同期処理を切り替える方法を模索しました。

解決策: オーバーロードを使う

オーバーロードを利用することで引数ごとに返り値の型を定義してやることで、やりたかったことが実現できました。

function exampleFunction(async: true): Promise<string>;
function exampleFunction(async?: false): string;
function exampleFunction(async?: boolean): string | Promise<string> {
  if (async) {
    return Promise.resolve("Hello, World!");
  } else {
    return "Hello, World!";
  }
}

const result1 = exampleFunction(); // string
const result2 = exampleFunction(true); // Promise<string>

Playground

オプションがtrueの場合とfalseの場合でそれぞれ関数の型を定義すれば引数の値に応じて型を変化させることができます。

このようにオーバーロードを用いて1つの関数に異なる型を持たせることで、同じロジックを再利用できます。

これにより、重複コードを減らし、メンテナンス性を向上させることができます。

TypeScriptのオーバーロード概要

稚拙ながらTypeScriptのオーバーロード機能をこれまで知りませんでした。

上述した通りオーバーロードによりひとつの関数に複数のシグネチャを持たせることができます。

Javaのようなシグネチャごとに実装が書ける仕組みになっていないのは、JavaScriptにオーバーロードがなく、TypeScriptのコードを見てJavaScriptが予測できるようにするためだそうです。

参考: https://typescriptbook.jp/reference/functions/overload-functions#なぜjavaのようなオーバーロードではないのか

使用の際には関数シグネチャの記述順やアロー関数のオーバーロードなど注意することがあるようです。詳細は詳しい記事をご覧ください。

https://typescriptbook.jp/reference/functions/overload-functions

https://js.studio-kingdom.com/typescript/declaration_files/dos_and_donts#function_overloads

ボツ案: ジェネリクスの使用

ジェネリクスを使って返り値の型を出し分ければできるかと思いましたが、うまくいきませんでした。

const exampleFunction = <T extends boolean = false>(async: T): T extends true ? Promise<string> : string => {
  if (async) {
    return Promise.resolve("Hello, World!"); // コンパイルエラー
    // 型 'Promise<string>' を型 'T extends true ? Promise<string> : string' に割り当てることはできません。
  }

  return "Hello, World!"; // コンパイルエラー
  // 型 'string' を型 'T extends true ? Promise<string> : string' に割り当てることはできません。
};

型チェックの時点で Ttrue または false であるかどうかを決定できないため、適切な型の割り当てができないようです。

余談: オーバーロードで実現できることに気づいた経緯

同期非同期を動的に切り替える方法を考えていました。

たまたま引数に応じて返り値の型を変化させていたライブラリを見かけて実装を覗いてみました。

そうしたらまさにやりたかった同期関数と非同期関数の動的切り替えを実現していました!

ライブラリのコードなどいろいろなコードを読んで学ぶ大切さに改めて気付かされました。

まとめ

オーバーロードにより、関数の返り値の型を引数に応じて変えることができます。

同期関数と非同期関数の切り替えだけに限らず使えるテクニックで、知っていると役立つシーンがありそうです!

ひとつの関数に異なる関数の型を与えたいときはぜひ活用ください!

atama plus techblog
atama plus techblog

Discussion