【TypeScript】コールバック関数の型設計について

# はじめに
Reactのコンポーネント作成の際に、propsとして受け取る関数の型設計につまずいたため、自分なりの理解をまとめておきます。
関数型の部分型関係の話にもなるので内容は難しめですが、参考になれば幸いです。

# 結論
**コールバック関数を受け取る側について、コールバック関数の**
- 返り値の型は広いほうが良い！（コールバック関数を広く受け入れる）
- 引数の型は狭いほうがいい！（渡される引数の型を限定してあげる）
- 引数の数は多いほうが良い（多すぎるのも使いづらくなるが）
<br />

コールバック関数を渡す側の視点だと、
- 返り値の型は、受け取る側で指定されている型と同じか狭くないといけない
- 引数の型は、受け取る側で指定されている型と同じか広くないといけない
- 引数の数は、受け取る側で指定されている数と同じか少ないといけない

# サンプルコードの紹介

まず、`doSomethingWithKeyAndValue`関数を用意しています。
この関数は、内部的に`data`（`key`と`value`というプロパティを持つオブジェクトの配列）を持っています。
`data`の各要素の`key`と`value`に対して、コールバック関数を実行させ、各要素の実行結果を配列にして返す関数です。
また、コールバック関数の引数は、基本的に`key`と`value`です。

以下の例では、`doSomethingWithKeyAndValue`関数に、`myFn`というコールバック関数を渡しています。
この関数`myFn`は、`key`と`value`を半角スペースで結合する関数です。
そのため、`doSomethingWithKeyAndValue`関数の返り値は、`key`と`value`が半角スペースで結合された要素からなる配列を返します。

```typescript: コールバック関数を受け取る側
const doSomethingWithKeyAndValue = (
  callbackfn: (key: string, value: string) => string
): Array<string> => {

  const data = [
    { key: "red", value: "赤" },
    { key: "blue", value: "青" },
    { key: "gray", value: "灰" },
  ];
  return data.map((item) => {
    return callbackfn(item.key, item.value); // ここでコールバック関数を実行
  });
};
```
```typescript: コールバック関数を渡す側
// key と value を半角スペースで連結する関数
const myFn = (key: string, value: string): string => {
  return key + " " + value;
};

console.log(doSomethingWithKeyAndValue(myFn));
```
```typescript: 実行結果
["red 赤", "blue 青", "gray 灰"]
```

また、上記の例の場合、

コールバック関数を受け取る側の型は`(key: string, value: string) => string`
実際に渡しているコールバック関数の型も`(key: string, value: string) => string`

になります。

# コールバック関数の返り値の型

まずは、コールバック関数を受け取る側と渡す側の、コールバック関数の返り値の型に違いがある場合を検証していきます。

## コールバック関数を受け取るほうが、返り値の型の範囲が広い場合✅

以下のコードは、

- コールバック関数を受け取る側の型は`(key: string, value: string) => string | number`
- 実際に渡しているコールバック関数の型は`(key: string, value: string) => string`

となっています。

返り値の型に注目すると
- コールバック関数を受け取る側の返り値の型は`string | number`
- 実際に渡しているコールバック関数の返り値の型は`string`

となります。

このコードは問題ないです。

```typescript: コールバック関数を受け取る側
const doSomethingWithKeyAndValue = (
  callbackfn: (key: string, value: string) => string | number
): Array<string | number> => {

  const data = [
    { key: "red", value: "赤" },
    { key: "blue", value: "青" },
    { key: "gray", value: "灰" },
  ];
  return data.map((item) => {
    return callbackfn(item.key, item.value); // ここでコールバック関数を実行
  });
};
```
```typescript: コールバック関数を渡す側
const myFn = (key: string, value: string): string => {
  return key + " " + value;
};

console.log(doSomethingWithKeyAndValue(myFn));
```
```typescript: 実行結果
["red 赤", "blue 青", "gray 灰"]
```

コールバック関数を受け取る側は、コールバック関数の返り値が`string`型か`number`型であることを想定しているのに対し、
実際に渡されるコールバック関数`myFn`の返り値は`string`型なので、
コールバック関数を受け入れ可能となります。

## コールバック関数を受け取るのほうが、返り値の型の範囲が狭い場合❌

以下のコードは、

コールバック関数を受け取る側の型は`(key: string, value: string) => string`
実際に渡しているコールバック関数の型は`(key: string, value: string) => string | number`

となっています。

返り値の型に注目すると
- コールバック関数を受け取る側の返り値の型は`string`
- 実際に渡しているコールバック関数の返り値の型は`string | number`

となります。

このコードの場合、`doSomethingWithKeyAndValue`関数の実行部分でコンパイルエラーとなります。

```typescript: コールバック関数を受け取る側
const doSomethingWithKeyAndValue = (
  callbackfn: (key: string, value: string) => string
): Array<string> => {
  
  const data = [
    { key: "red", value: "赤" },
    { key: "blue", value: "青" },
    { key: "gray", value: "灰" },
  ];
  return data.map((item) => {
    return callbackfn(item.key, item.value); // ここでコールバック関数を実行
  });
};
```
```typescript: コールバック関数を渡す側
const myFn = (key: string, value: string): string | number => {
  if (key === "gray") {
    return 808080;
  }
  return key + " " + value;
}

console.log(doSomethingWithKeyAndValue(myFn)); // myFnでコンパイルエラー！
```
> 型 '(key: string, value: string) => string | number' の引数を型 '(key: string, value: string) => string' のパラメーターに割り当てることはできません。
> 型 'string | number' を型 'string' に割り当てることはできません。
> 型 'number' を型 'string' に割り当てることはできません。
```typescript: 実行結果
["red 赤", "blue 青", 808080]
```

コールバック関数を受け取る側は、コールバック関数の返り値が`string`型であることを想定しているのに対し、
実際に渡されるコールバック関数`myFn`は`string`型以外にも`number`型を返す可能性があるため、
コールバック関数を受け入れることができずコンパイルエラーとなります。

（実行自体はできますが）

## まとめ
コールバック関数を受け取る側について、コールバック関数の返り値の型は広いほうが良い！（コールバック関数を広く受け入れる）


# コールバック関数の引数の型

次に、コールバック関数を受け取る側と渡す側の、コールバック関数の引数の型に違いがある場合を検証していきます。

## コールバック関数を受け取るほうが、引数の型の範囲が広い場合❌

以下のコードは、

コールバック関数を受け取る側の型は`(key: string, value: string | number) => string`
実際に渡しているコールバック関数の型は`(key: string, value: string) => string`

となっています。

引数`value`の型に注目すると
- コールバック関数を受け取る側の引数`value`の型は`string | number`
- 実際に渡しているコールバック関数の引数`value`の型は`string`

となります。

このコードの場合、`doSomethingWithKeyAndValue`関数の実行部分でコンパイルエラーとなります。

```typescript: コールバック関数を受け取る側
const doSomethingWithKeyAndValue = (
  callbackfn: (key: string, value: string | number) => string
): Array<string> => {

  const data = [
    { key: "red", value: "赤" },
    { key: "blue", value: "青" },
    { key: "gray", value: 808080 },
  ];
  return data.map((item) => {
    return callbackfn(item.key, item.value); // ここでコールバック関数を実行
  });
};
```
```typescript: コールバック関数を渡す側
const myFn = (key: string, value: string): string => {
  return key + " " + value;
};

console.log(doSomethingWithKeyAndValue(myFn)); // myFnでコンパイルエラー
```
> 型 '(key: string, value: string) => string' の引数を型 '(key: string, value: string | number) => string' のパラメーターに割り当てることはできません。
> パラメーター 'value' および 'value' は型に互換性がありません。
> 型 'string | number' を型 'string' に割り当てることはできません。
> 型 'number' を型 'string' に割り当てることはできません。
```typescript: 実行結果
["red 赤", "blue 青", "gray 808080"]
```

**コールバック関数を受け取る側は、コールバック関数の引数`value`に`string`型か`number`型の値を渡しますが、
実際に渡されるコールバック関数`myFn`の引数`value`は`string`型が渡されることしか考慮されていないため、
コールバック関数を受け入れることができずコンパイルエラーとなります。**

（TypeScriptで文字列と数字を+演算子で結合すると、数字が文字列に変換され、文字列として連結されるため、実行自体はできますが）


## コールバック関数を受け取るほうが、引数の型の範囲が狭い場合✅

以下のコードは、

コールバック関数を受け取る側の型は`(key: string, value: string) => string`
実際に渡しているコールバック関数の型は`(key: string, value: string | number) => string`

となっています。

引数`value`の型に注目すると
- コールバック関数を受け取る側の引数`value`の型は`string`
- 実際に渡しているコールバック関数の引数`value`の型は`string | number`

となります。

このコードは問題ないです。

```typescript: コールバック関数を受け取る側
const doSomethingWithKeyAndValue = (
  callbackfn: (key: string, value: string) => string
): Array<string> => {

  const data = [
    { key: "red", value: "赤" },
    { key: "blue", value: "青" },
    { key: "gray", value: "灰" },
  ];
  return data.map((item) => {
    return callbackfn(item.key, item.value); // ここでコールバック関数を実行
  });
};
```
```typescript: コールバック関数を渡す側
const myFn = (key: string, value: string | number): string => {
  return key + " " + String(value);
};

console.log(doSomethingWithKeyAndValue(myFn));
```
```typescript: 実行結果
["red 赤", "blue 青", "gray 灰"]
```

コールバック関数を受け取る側は、コールバック関数の引数`value`に`string`型の値を渡し、
実際に渡されるコールバック関数`myFn`の引数`value`は`string`型か`number`型の値を渡せるので、
コールバック関数を受け入れ可能となります。

## まとめ
コールバック関数を受け取る側について、コールバック関数の引数の型は狭いほうがいい！（渡される引数の型を限定してあげる）


# コールバック関数の引数の数

最後に、コールバック関数を受け取る側と渡す側の、コールバック関数の引数の数に違いがある場合を検証していきます。

## コールバック関数を受け取るほうが、引数の数が多い場合✅

以下のコードは、

コールバック関数の型は`(key: string, value: string, code: string) => string`
実際に渡している関数の型は`(key: string, value: string) => string`

となっています。

引数の数に注目すると
- コールバック関数を受け取る側の引数は`(key: string, value: string, code: string)`
- 実際に渡しているコールバック関数の引数は`(key: string, value: string)`

となります。

このコードは問題ないです。

```typescript: コールバック関数を受け取る側
const doSomethingWithKeyAndValueAndCode = (
  callbackfn: (key: string, value: string, code: string) => string
): Array<string> => {

  const data = [
    { key: "red", value: "赤", code: "#ff0000" },
    { key: "blue", value: "青", code: "#0000ff" },
    { key: "gray", value: "灰", code: "#808080" },
  ];
  return data.map((item) => {
    return callbackfn(item.key, item.value, item.code); // ここでコールバック関数を実行
    // ↑余分な引数 item.code は捨てられる
  });
};
```
```typescript: コールバック関数を渡す側
const myFn = (key: string, value: string): string => {
  return key + " " + value;
};

console.log(doSomethingWithKeyAndValueAndCode(myFn));
```
```typescript: 実行結果
["red 赤", "blue 青", "gray 灰"]
```

**コールバック関数を受け取る側のコールバック関数の型を見ると、引数を3つ指定しなければいけないように見えますがそうではありません。
コールバック関数の引数は3つ`(key: string, value: string, code: string)`渡されるが、どの引数まで使うのかはコールバック関数を渡す側で決められる、ということになります。**

この場合、余分な引数は捨てられます。

他の具体例で考えてみると、[map関数のコールバック関数](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map#%E5%BC%95%E6%95%B0)は第1引数に`element`、第2引数に`index`、第3引数に`array`が渡されるが、map関数のコールバック関数を指定する際は第1引数の`element`だけを使うこともできる、というのがわかりやすいかと思います。

（第2引数だけ使いたい場合は`myArray.map((_, index) => index;`のように、使わない引数をアンダースコア等にするのが慣例かと思います。）

## コールバック関数を受け取るほうが、引数の数が少ない場合❌

以下のコードは、

コールバック関数を受け取る側の型は`(key: string, value: string) => string`
実際に渡しているコールバック関数の型は`(key: string, value: string, code: string) => string`

となっています。

引数の数に注目すると
- コールバック関数を受け取る側の引数は`(key: string, value: string)`
- 実際に渡しているコールバック関数の引数は`(key: string, value: string, code: string)`

となります。

このコードの場合、`doSomethingWithKeyAndValue`関数の実行部分でコンパイルエラーとなります。

```typescript: コールバック関数を受け取る側
const doSomethingWithKeyAndValue = (
  callbackfn: (key: string, value: string) => string
): Array<string> => {

  const data = [
    { key: "red", value: "赤" },
    { key: "blue", value: "青" },
    { key: "gray", value: "灰" },
  ];
  return data.map((item) => {
    return callbackfn(item.key, item.value); // ここでコールバック関数を実行
  });
};
```
```typescript: コールバック関数を渡す側
const myFn = (key: string, value: string, code: string): string => {
  return key + " " + value + " " + code;
};

console.log(doSomethingWithKeyAndValue(myFn)); // myFnでコンパイルエラー
```
> 型 '(key: string, value: string, code: string) => string' の引数を型 '(key: string, value: string) => string' のパラメーターに割り当てることはできません。
> Target signature provides too few arguments. Expected 3 or more, but got 2.

```typescript: 実行結果
["red 赤 undefined", "blue 青 undefined", "gray 灰 undefined"]
```

コールバック関数の引数は2つ（`key: string, value: string`）渡されるが、
実際に渡しているコールバック関数`myFn`は3つの引数の指定を必要としているため、
コールバック関数を受け入れることができずコンパイルエラーとなります。

（実行時、`myFn`の引数`code`には`undefined`が渡され、文字列との連結時に`undefined`も文字列に変換されるため、実行自体はできますが）

また、渡しているコールバック関数`myFn`の第3引数をオプショナルにするとコンパイルエラーは解消されます。

```typescript: コールバック関数を渡す側
const myFn = (key: string, value: string, code?: string): string => {
  if (code) {
    return key + " " + value + " " + code;
  }
  return key + " " + value;
};

console.log(doSomethingWithKeyAndValue(myFn));
```
```typescript: 実行結果
["red 赤", "blue 青", "gray 灰"]
```



### まとめ
コールバック関数を受け取る側について、コールバック関数の引数の数は多いほうが良い（多すぎるのも使いづらくなるが）

# おわりに

ちなみに、冒頭の「サンプルコードの紹介」で紹介したコードについて、使い勝手が良くなるように書いたコードは以下のようになるかと思います。
ジェネリクスを使って、コールバック関数の返り値を任意に（返り値の型を広く）しています。
また、コールバック関数の返り値の型によって、`doSomethingWithKeyAndValue`関数の返り値の型も調整してくれます。

```typescript: コールバック関数を受け取る側
const doSomethingWithKeyAndValue = <T>(
  callbackfn: (key: string, value: string) => T
): Array<T> => {
  const data = [
    { key: "red", value: "赤" },
    { key: "blue", value: "青" },
    { key: "gray", value: "灰" },
  ];
  return data.map((item) => {
    return callbackfn(item.key, item.value); // ここでコールバック関数を実行
  });
};
```
```typescript: コールバック関数を渡す側
const myFn = (key: string, value: string): string => {
  return key + " " + value;
};

console.log(doSomethingWithKeyAndValue(myFn));
```
```typescript: 実行結果
["red 赤", "blue 青", "gray 灰"]
```

# 参考文献

https://gihyo.jp/book/2022/978-4-297-12747-3