はじめに
Reactのコンポーネント作成の際に、propsとして受け取る関数の型設計につまずいたため、自分なりの理解をまとめておきます。
関数型の部分型関係の話にもなるので内容は難しめですが、参考になれば幸いです。
結論
コールバック関数を受け取る側について、コールバック関数の
- 返り値の型は広いほうが良い!(コールバック関数を広く受け入れる)
- 引数の型は狭いほうがいい!(渡される引数の型を限定してあげる)
- 引数の数は多いほうが良い(多すぎるのも使いづらくなるが)
コールバック関数を渡す側の視点だと、
- 返り値の型は、受け取る側で指定されている型と同じか狭くないといけない
- 引数の型は、受け取る側で指定されている型と同じか広くないといけない
- 引数の数は、受け取る側で指定されている数と同じか少ないといけない
サンプルコードの紹介
まず、doSomethingWithKeyAndValue関数を用意しています。
この関数は、内部的にdata(keyとvalueというプロパティを持つオブジェクトの配列)を持っています。
dataの各要素のkeyとvalueに対して、コールバック関数を実行させ、各要素の実行結果を配列にして返す関数です。
また、コールバック関数の引数は、基本的にkeyとvalueです。
以下の例では、doSomethingWithKeyAndValue関数に、myFnというコールバック関数を渡しています。
この関数myFnは、keyとvalueを半角スペースで結合する関数です。
そのため、doSomethingWithKeyAndValue関数の返り値は、keyとvalueが半角スペースで結合された要素からなる配列を返します。
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); // ここでコールバック関数を実行
});
};
// key と value を半角スペースで連結する関数
const myFn = (key: string, value: string): string => {
return key + " " + value;
};
console.log(doSomethingWithKeyAndValue(myFn));
["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
となります。
このコードは問題ないです。
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); // ここでコールバック関数を実行
});
};
const myFn = (key: string, value: string): string => {
return key + " " + value;
};
console.log(doSomethingWithKeyAndValue(myFn));
["red 赤", "blue 青", "gray 灰"]
コールバック関数を受け取る側は、コールバック関数の返り値がstring型かnumber型であることを想定しているのに対し、
実際に渡されるコールバック関数myFnの返り値はstring型なので、
コールバック関数を受け入れ可能となります。
コールバック関数を受け取るのほうが、返り値の型の範囲が狭い場合❌
以下のコードは、
コールバック関数を受け取る側の型は(key: string, value: string) => string
実際に渡しているコールバック関数の型は(key: string, value: string) => string | number
となっています。
返り値の型に注目すると
- コールバック関数を受け取る側の返り値の型は
string - 実際に渡しているコールバック関数の返り値の型は
string | number
となります。
このコードの場合、doSomethingWithKeyAndValue関数の実行部分でコンパイルエラーとなります。
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); // ここでコールバック関数を実行
});
};
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' に割り当てることはできません。
["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関数の実行部分でコンパイルエラーとなります。
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); // ここでコールバック関数を実行
});
};
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' に割り当てることはできません。
["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
となります。
このコードは問題ないです。
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); // ここでコールバック関数を実行
});
};
const myFn = (key: string, value: string | number): string => {
return key + " " + String(value);
};
console.log(doSomethingWithKeyAndValue(myFn));
["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)
となります。
このコードは問題ないです。
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 は捨てられる
});
};
const myFn = (key: string, value: string): string => {
return key + " " + value;
};
console.log(doSomethingWithKeyAndValueAndCode(myFn));
["red 赤", "blue 青", "gray 灰"]
コールバック関数を受け取る側のコールバック関数の型を見ると、引数を3つ指定しなければいけないように見えますがそうではありません。
コールバック関数の引数は3つ(key: string, value: string, code: string)渡されるが、どの引数まで使うのかはコールバック関数を渡す側で決められる、ということになります。
この場合、余分な引数は捨てられます。
他の具体例で考えてみると、map関数のコールバック関数は第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関数の実行部分でコンパイルエラーとなります。
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); // ここでコールバック関数を実行
});
};
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.
["red 赤 undefined", "blue 青 undefined", "gray 灰 undefined"]
コールバック関数の引数は2つ(key: string, value: string)渡されるが、
実際に渡しているコールバック関数myFnは3つの引数の指定を必要としているため、
コールバック関数を受け入れることができずコンパイルエラーとなります。
(実行時、myFnの引数codeにはundefinedが渡され、文字列との連結時にundefinedも文字列に変換されるため、実行自体はできますが)
また、渡しているコールバック関数myFnの第3引数をオプショナルにするとコンパイルエラーは解消されます。
const myFn = (key: string, value: string, code?: string): string => {
if (code) {
return key + " " + value + " " + code;
}
return key + " " + value;
};
console.log(doSomethingWithKeyAndValue(myFn));
["red 赤", "blue 青", "gray 灰"]
まとめ
コールバック関数を受け取る側について、コールバック関数の引数の数は多いほうが良い(多すぎるのも使いづらくなるが)
おわりに
ちなみに、冒頭の「サンプルコードの紹介」で紹介したコードについて、使い勝手が良くなるように書いたコードは以下のようになるかと思います。
ジェネリクスを使って、コールバック関数の返り値を任意に(返り値の型を広く)しています。
また、コールバック関数の返り値の型によって、doSomethingWithKeyAndValue関数の返り値の型も調整してくれます。
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); // ここでコールバック関数を実行
});
};
const myFn = (key: string, value: string): string => {
return key + " " + value;
};
console.log(doSomethingWithKeyAndValue(myFn));
["red 赤", "blue 青", "gray 灰"]
参考文献