はじめに
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 灰"]
参考文献