TypeScriptのTips - カリー化で業務ルールと処理を分離する
自分がコーディングで使っている、ちょっとしたテクニックを紹介します。
今回のテーマは カリー化 です。
このテクニックを使うことで、業務ルールと処理を分離し再利用性・テスト容易性が向上します。
サンプルとして、手数料を計算する処理を題材にします。
要件は以下の通りとします。
- 関数は金額を受け取り、適用する手数料を返す
- 手数料は価格帯によって決定する
- 0 ~ 499円は手数料 300円
- 500 ~ 1999円は手数料 50円
- 2000円以上は手数料なし
- 場合によっては手数料が複数かかる場合もある
よくある実装例
まずは、よくある実装例です。
(そもそもハードコードがよくない、などは目を瞑ってほしい。)
type FeeRule = {
min: number;
max: number | null;
fee: number;
};
const calculateFee = (amount: number): number => {
const feeRules: FeeRule[] = [
{ min: 0, max: 499, fee: 300 },
{ min: 500, max: 1999, fee: 50 },
{ min: 2000, max: null, fee: 0 },
];
return feeRules
.filter(rule =>
amount >= rule.min &&
(rule.max === null || amount <= rule.max)
)
.reduce((sum, rule) => sum + rule.fee, 0);
};
要件は満たしてるしパッとみ問題なさそうに見えますが、いくつかの改善点があります。
今回はこの関数をカリー化という手法でリファクタリングしてみます。
カリー化後の実装
type FeeRule = {
min: number;
max: number | null;
fee: number;
};
// カリー化して手数料ルールを引数から注入する。処理自体は具体的な業務ルールを知らない。
const createFeeCalculator =
(rules: FeeRule[]) =>
(amount: number): number =>
rules
.filter(rule =>
amount >= rule.min &&
(rule.max === null || amount <= rule.max)
)
.reduce((sum, rule) => sum + rule.fee, 0);
const feeRules = [
{ min: 0, max: 499, fee: 300 },
{ min: 500, max: 1999, fee: 50 },
{ min: 2000, max: null, fee: 0 },
] as const satisfies FeeRule[];
const calculateFee = createFeeCalculator(feeRules)
変更点はcreateFeeCalculator内で宣言していたfeeRulesを引数で注入するようにしたことです。
これによって処理と業務ルールが明確に分離されました。
const createFeeCalculator =
(rules: FeeRule[]) =>
(amount: number): number => {
/* ... */
};
この関数が知っているのは、
- 金額の比較方法
- 条件に合うルールの抽出方法
- 手数料の合算方法
といった 計算の仕方そのもの のみで、具体的な金額の閾値は知りません。
一方で
const feeRules = [
{ min: 0, max: 499, fee: 300 },
{ min: 500, max: 1999, fee: 50 },
{ min: 2000, max: null, fee: 0 },
];
こちらは、
- どの価格帯で
- いくら手数料を取るか
という 業務ルールそのもの を表しています。
このように処理と業務ルールを分離することで再利用性・テスト容易性が高まります。
再利用性
処理と業務ルールが分離されていることで、処理の再利用性が高まります。
例えば、プレミアム会員の場合に手数料ルールが変わる、という要件変更を考えます。
カリー化前のコードでは、処理と業務ルールが一体化しているために処理自体を2重に実装したり、改修したりする必要があります。これはDRYに違反したり、既存の処理を破壊する可能性を伴います。
カリー化後のコードでは、createFeeCalculatorの引数に渡すfeeRulesを変更するだけで済みます。
// リファクタリング後
type FeeRule = {
min: number;
max: number | null;
fee: number;
};
const createFeeCalculator =
(rules: FeeRule[]) =>
(amount: number): number =>
rules
.filter(rule =>
amount >= rule.min &&
(rule.max === null || amount <= rule.max)
)
.reduce((sum, rule) => sum + rule.fee, 0);
const feeRules = [
{ min: 0, max: 499, fee: 300 },
{ min: 500, max: 1999, fee: 50 },
{ min: 2000, max: null, fee: 0 },
] as const satisfies FeeRule[];
const calculateFee = createFeeCalculator(feeRules)
// 以下を追加するだけで対応できる
const feeRulesForPremium = [
{ min: 0, max: 499, fee: 300 },
{ min: 500, max: null, fee: 0 },
] as const satisfies FeeRule[];
// プレミアム会員向けの手数料を計算する
const calculateFeeForPremiumMember = createFeeCalculator(feeRulesForPremium)
テスト容易性
カリー化前は処理と業務ルールが一体になっているため、現状の業務ルールに合わせたテストコードを書くことになります。
一方カリー化後は処理が業務ルールと分離されてるため、テスト用の手数料ルール(feeRules)を注入しテストできます。
テスト用に手数料ルールを注入できると
- 現状の業務ルールでは再現できないケースもテストできる
- 業務ルール変更の影響を処理のテストコードが受けない
のようなメリットがあります。
現状の業務ルールではある価格帯に対して手数料が単一です。
そのためリファクタリング前のテストコードでは、ある価格帯に対して複数の手数料ルールがかかるときにどうなるか?のテストを書くことはできません。
一方リファクタリング後はテスト用の手数料ルールを注入できるため、現在の業務ルールでは再現しないエッジケースなどもテスト可能です。
これは将来的な不具合の予防につながります。
また、例えば手数料300円の価格帯を0~1000円に変更する場合に、
カリー化前は関数が内包する業務ルールの境界値が変わるためテストコードも修正が必要になります。
カリー化後は、処理のテストにはテスト用の手数料ルールを注入してるため、業務ルール都合で処理に対するテストコードの修正が不要になります。
まとめ
今回紹介したカリー化のテクニックを使い業務ルールと処理を分離することで、コードの再利用性・テスト容易性が高まります。
テストがしやすく再利用しやすくなるということは、コードの変更が容易になることを意味します。
このテクニックは例えば以下のようなケースにも適用可能で、意外と活用できる機会は多いです。
- バリデーションの違反チェック処理と、各項目のバリデーションルール
- 権限チェック処理と、各ロールがもつ権限ルール
- 校閲用のワード検閲処理と、NGワード
どこかで参考になれば嬉しいです。
Discussion