📝

anyを使わずにTypeScriptで任意引数を取る関数のGenericsを記述する

2023/06/25に公開

TL;DR;

extends する関数の引数を...args: never[]で書く。

例えば、

// 任意の関数をextendsするGenericsの使用例
type ExampleType<F extends (...args: never[]) => unknown> = /* Fを使った型定義の記述 */

のような感じ。

(もう少し噛み砕いて)今回やりかたったこと

汎用的なモジュールなどを作っていて、引数の取り方が様々な関数を統一的に扱いたくなることがある。

こういうときに Generics を使って、

type ReturnType = /* 関数の返り値の型 */

type ExampleType<Function extends (...args: any[]) => ReturnType> = /* Functionを使った型定義 */

のような感じで、...args: any[]部分で任意引数を表現できる。

しかし、any を使っているため linter に怒られがちだし、気分も若干良くない(?)ので、any を避けて記述してみる。

解決策と考察

冒頭に書いたように

  • ...args: never[]

を使えば良い。すなわち、さっきの疑似コードだと、

type ReturnType = /* 関数の返り値の型 */

// ↓...args: any[] を...args: never に変更している
type ExampleType<Function extends (...args: never[]) => ReturnType> = /* Functionを使った型定義 */

のような感じで記述できる。

一方、初心者的にまず思いつきがちな(?)...args: unknown[]は型エラーになり使うことが出来ない。
この辺の議論は個人的に stackoverflow:

https://stackoverflow.com/questions/75183307/args-never-vs-args-unknown-in-typescript

が参考になったりした。

unknownneverはそれぞれ対をなす型であり、

  • unknown型は任意の値を割り当てることができるが、決して他の変数に代入することができない
  • never型は一切の値を割り当てることができないが、任意の変数に代入可能

といった性質を持つ。例えば、

// unknown
const a: unknown = 1; // OK: 右辺が数値でも文字列でも配列でもなんでもOK
const b = a; // NG: aにどんな値が入っていようが型エラー

// never
const x: never = 1; // NG: 右辺が数値でも文字列でも配列でも任意のものを受け付けない

declare;
y: never;
const z: number = y; // OK: zがnumberでもstringでもArrayでも常にOK

のような感じになる。
(一番最後の、never を number でも string[]でもなんにでも代入できるのは、普通は機会がないので結構意外だった)

今回のような任意の引数を受ける関数を表現するためには、どのような具体的な引数にも代入できるような引数を持つ型を extend すれば良く、
そうなると何にも代入できないunknown[]を引数にとる関数型を extends することはできず、
逆に何にでも代入できるnever[]を引数にとる関数型であれば必ず extends できる。

あるいは、共変・反変性を使うと、関数の引数は反変性、返り値は共変性を持つことで説明できる。
A extends Bのときに、A は B の部分型になっている必要があるが、A とBが関数型のとき、
すなわち

type A = (...args: P) => Q;
type B = (...args: R) => S;

のとき、A が B の部分型ならば

  • R は P の部分型
  • Q は S の部分型

をそれぞれ満たす必要がある。
(引数と返り値では依存関係の向きが逆)

ここで、A を任意の関数として置く場合、B の返り値 S は任意の型を部分型に持つunknownにすれば良いが、
B の引数 R は任意の型の部分型になっている(=何にでも代入できる)neverを使えば良い(関数の引数は配列型で表現されるので実際はnever[])ということになる。

具体例

一応検証可能なように、
(後述の通り、あまり良い例ではないが、他に良いのが思いつかなかったので)それっぽい例を置いておく。

なお、検証は TypeScript5.0.4: deno 1.34.3 (release, x86_64-unknown-linux-gnu)によって行っている。

任意の関数を受け取り、指定したメッセージをconsole.logに吐いてから元の関数を実行する、といった関数(wrapFunction)を色々な関数に適用した例:

function wrapFunction<F extends (...args: never[]) => unknown>(
  func: F,
  message: string
): F {
  const wrapped = (...args: Parameters<F>) => {
    console.log(message);
    return func(...args);
  };
  // ↓`wrapped`の型をFを満たすように出来ないため、やむなく`as F`をしている
  return wrapped as F;
}

/** 'func1'を出力する関数 */
function func1() {
  console.log("func1");
}

const wrappedFunc1 = wrapFunction(func1, "wrapped(1)");
wrappedFunc1();
// wrapped(1)
// func1

/** 受け取った数値の合計を返す関数 */
function func2(...args: number[]) {
  return args.reduce((sum, value) => sum + value);
}
const wrappedFunc2 = wrapFunction(func2, "wrapped(2)");
const sum = wrappedFunc2(1, 2, 3, 4, 5);
// wrapped(2)
console.log(sum);
// 15

/** 引数で指定した文字列に応じた標準出力を行う関数 */
function func3(
  firstName: string,
  lastName: string,
  opts: {
    lang: "en" | "ja";
  } = { lang: "en" }
) {
  if (opts.lang === "ja") {
    console.log(`こんにちは、${lastName} ${firstName}さん!`);
  } else {
    console.log(`Hello, ${firstName} ${lastName}.`);
  }
}

const wrappedFunc3 = wrapFunction(func3, "wrapped(3)");
wrappedFunc3("Jane", "Doe");
// wrapped(3)
// Hello, Jane Doe.

wrappedFunc3("太郎", "山田", { lang: "ja" });
// wrapped(3)
// こんにちは、山田 太郎さん!

実行すると、コメントアウトしている部分の通りに標準出力がなされ、期待通りに動作することがわかる。
またコードを一部書き換えてみれば、...args: never[]部分を...args: unknown[]にすると型エラーが起こることなども確認できる。

もっともwrapFunctionの中でasをやむなく使っていたりと完全に適切な例とは言い難い感じがあるが、別にany[]にしても同様に起こる事象なので、よりましにはできている。

とにかく、これでanyを登場させずに任意の関数を Generics を使って受け取ることが出来ている。

GitHubで編集を提案

Discussion