TypeScript | 関数・クラス・ジェネリクス:関数設計の深化 – 高階関数の型付け

TypeScript
スポンサーリンク

まず「高階関数」をちゃんと定義しておく

高階関数(higher-order function)は、ざっくり言うと

「関数を受け取る」か「関数を返す」か、あるいはその両方をする関数

のことです。

function doTwice(fn: () => void) {
  fn();
  fn();
}

function createAdder(a: number) {
  return (b: number) => a + b;
}
TypeScript

doTwice は「関数を引数に取る関数」、
createAdder は「関数を返す関数」です。

TypeScript で高階関数を扱うときのポイントは、

「関数を“値”として扱う」
「その関数にも、ちゃんと型(引数・戻り値)がある」

この2つを、型レベルでしっかり表現してあげることです。


基本1:関数を「引数として受け取る」高階関数の型付け

一番シンプルな高階関数

まずは「引数として関数を受け取る」パターンから。

function doTwice(fn: () => void) {
  fn();
  fn();
}
TypeScript

ここでの型は fn: () => void です。

「引数なしで呼べて、戻り値を使わない関数」

という意味になります。

呼び出し側はこう書きます。

doTwice(() => {
  console.log("呼ばれた");
});
TypeScript

ここで大事なのは、

高階関数の「関数引数」は、
普通の関数型と同じく (引数の型) => 戻り値の型 で書く

という感覚です。

まずはこのレベルを、当たり前に書けるようにしておきます。

引数ありの関数を受け取る高階関数

例えば、「配列の各要素に対して処理をする」高階関数。

function forEachNumber(
  numbers: number[],
  fn: (value: number, index: number) => void
) {
  for (let i = 0; i < numbers.length; i++) {
    fn(numbers[i], i);
  }
}
TypeScript

ここでの fn の型は (value: number, index: number) => void

「要素とインデックスを受け取って、何かする関数」

という契約です。

呼び出し側はこうなります。

forEachNumber([10, 20, 30], (value, index) => {
  console.log(index, value * 2);
});
TypeScript

ポイントは、

「高階関数の中で fn に何を渡しているか」が、そのまま fn の引数型になる

ということです。


基本2:関数を「返す」高階関数の型付け

関数を返す=「戻り値の型が関数型」

次は「関数を返す」パターン。

function createAdder(a: number) {
  return (b: number) => a + b;
}
TypeScript

型をちゃんと書くとこうです。

function createAdder(a: number): (b: number) => number {
  return (b: number) => a + b;
}
TypeScript

createAdder の型は、

number を受け取って、(b: number) => number を返す関数」

です。

使う側はこうなります。

const add10 = createAdder(10);
// add10: (b: number) => number

console.log(add10(5));  // 15
console.log(add10(20)); // 30
TypeScript

ここでの重要ポイントは、

「高階関数の戻り値の型として、“関数型”をそのまま書く」

ということです。

(引数) => 戻り値 を、
そのまま戻り値の型として使うイメージを持ってください。


基本3:関数を「受け取って返す」高階関数(ラッパー関数)

ログを挟むラッパー関数を例にする

高階関数らしさが一気に出るのが、「関数を受け取って、関数を返す」パターンです。

function withLog(fn: () => void) {
  return () => {
    console.log("start");
    fn();
    console.log("end");
  };
}
TypeScript

型だけ抜き出すと、

(fn: () => void) => () => void
TypeScript

です。

() => void を受け取って、() => void を返す関数」

という意味になります。

使ってみます。

const sayHello = () => {
  console.log("こんにちは");
};

const loggedHello = withLog(sayHello);

loggedHello();
// start
// こんにちは
// end
TypeScript

ここでのポイントは、

「高階関数は、“関数の振る舞いをラップして、同じ形の関数を返す”ことが多い」

ということです。

このとき、
「受け取る関数」と「返す関数」の型をどう一致させるか、が設計の肝になります。


一段上:ジェネリクスで「どんな関数でもラップできる」高階関数

any で妥協しない書き方

さっきの withLog を、どんな関数にも使えるようにしたいとします。

雑に書くとこうなります。

function withLogAny(fn: (...args: any[]) => any) {
  return (...args: any[]) => {
    console.log("call with:", args);
    return fn(...args);
  };
}
TypeScript

これは動きますが、any だらけで型安全ではありません。

TypeScript らしく書くなら、ジェネリクス+ユーティリティ型を使います。

function withLog<F extends (...args: any[]) => any>(fn: F) {
  return (...args: Parameters<F>): ReturnType<F> => {
    console.log("call with:", args);
    return fn(...args);
  };
}
TypeScript

ここで出てくる型が重要です。

F extends (...args: any[]) => any
「引数をいくつか取り、何かを返す関数型」

Parameters<F>
「関数 F の引数リスト(タプル型)」

ReturnType<F>
「関数 F の戻り値の型」

(...args: Parameters<F>): ReturnType<F> は、

「F と同じ引数を受け取り、F と同じ型を返す関数」

という意味になります。

使ってみます。

function add(a: number, b: number): number {
  return a + b;
}

const loggedAdd = withLog(add);
// loggedAdd: (a: number, b: number) => number

const result = loggedAdd(3, 5);
// call with: [3, 5]
// result: 8
TypeScript

ここでの超重要ポイントは、

「高階関数の型をジェネリクスで書くと、
“元の関数の引数・戻り値”をそのまま引き継いだラッパーを作れる」

ということです。

any[] ではなく Parameters<F>
any ではなく ReturnType<F> を使うことで、
引数の個数・型・順番・戻り値まで、すべて型安全に保てます。


型エイリアスで「高階関数の型」を整理する

関数型に名前をつけると、一気に読みやすくなる

例えば、「文字列を受け取って何かを返す関数」をよく扱うなら、
こういう型を用意できます。

type StringTransformer<T> = (value: string) => T;

function withTrim<T>(fn: StringTransformer<T>): StringTransformer<T> {
  return (value: string) => fn(value.trim());
}
TypeScript

ここでは、

StringTransformer<T> が「高階関数で扱う関数の“形”」
withTrim が「その関数を受け取って、同じ形の関数を返す高階関数」

という関係になっています。

使ってみます。

const toLength: StringTransformer<number> = (value) => value.length;

const toLengthAfterTrim = withTrim(toLength);

console.log(toLengthAfterTrim("  hello  ")); // 5
TypeScript

ここでのポイントは、

「高階関数で扱う“関数の形”に名前をつけておくと、
高階関数のシグネチャが一気に読みやすくなる」

ということです。

(value: string) => T を何度も書くより、
StringTransformer<T> としておいた方が、
「何を扱っているのか」がすぐに伝わります。


高階関数の型付けで意識してほしいこと

「関数の型」を2段階で見る癖をつける

高階関数の型は、よく見ると「二重構造」になっています。

例えば、さっきの withLog

function withLog<F extends (...args: any[]) => any>(
  fn: F
): (...args: Parameters<F>) => ReturnType<F> {
  // ...
}
TypeScript

これを分解すると、

外側の関数の型:
(fn: F) => (...args: Parameters<F>) => ReturnType<F>

内側で扱っている関数の型:
F(= (...args: any[]) => any の具体形)

という二重構造です。

高階関数を読むとき・書くときは、

「外側の関数は、どんな関数を受け取って、どんな関数を返すのか?」
「内側で扱っている“関数そのもの”の引数・戻り値はどうなっているか?」

この2段階で見る癖をつけると、一気に理解しやすくなります。


まとめ:高階関数の型付けを自分の言葉で言うと

最後に、あなた自身の言葉でこう整理してみてください。

高階関数は、

「関数を受け取る」「関数を返す」「その両方をする」関数で、
型の世界では「関数型を引数・戻り値として扱う関数」。

基本は、

関数を受け取るときは fn: (args) => result
関数を返すときは (): (args) => result
両方やるときは (fn: F) => (...args: Parameters<F>) => ReturnType<F>

ジェネリクスと Parameters / ReturnType を組み合わせると、
「元の関数と同じ引数・戻り値を持つラッパー」を型安全に作れる。

コードを書くとき、
「この高階関数は、“どんな関数”を受け取って、“どんな関数”を返すんだっけ?」
と一度立ち止まって、外側と内側の関数の型を分けて考えてみてください。

その一呼吸で、
高階関数は「難しそうなテクニック」から、
“関数の振る舞いを再利用・拡張するための、気持ちいい設計ツール” に変わっていきます。

タイトルとURLをコピーしました