Lesson 11 / 14
Higher-Order Functions
Parameterizing behavior: writing the same fee rule with a flag parameter, a lookup table, and a function parameter, and counting the lines touched and the core's decision points across the three versions when a new rule is added.
Contents
The previous two lessons took on data: the fee calculation was reduced to a mapping
from input to output, and the tariff was made immutable. Behavior stayed fixed.
calculateFee carries a single set of rules in its own body, and that body will grow
as a contracted discount, a volume discount, a night-delivery surcharge, and
customer-specific agreements are added.
This lesson turns behavior itself into a parameter. The same fee logic is written three separate ways: with a flag parameter, with a name-rate table, and with a function parameter. Then a real requirement arrives — a night-delivery surcharge — and the lines touched and the decision points accumulated in the core are counted across all three versions.
The Shared Core
All three versions start from the same base fee. The tariff data and the discount-free calculation live in a single file; the rules build on top of it.
// tariff.mjs — tariff data and the discount-free base fee export const TARIFF = { name: "domestic-standard", minFee: 4990, tiers: [ { maxWeightGrams: 1000, fee: 4990 }, { maxWeightGrams: 5000, fee: 6490 }, { maxWeightGrams: 15000, fee: 10900 }, ], zoneFactor: { 1: 1.0, 2: 1.15, 3: 1.35 }, }; export const SHIPMENTS = [ { code: "TR-4471", weightGrams: 800, zone: 1 }, { code: "TR-4472", weightGrams: 3200, zone: 2 }, { code: "TR-4473", weightGrams: 12400, zone: 3 }, ]; export function baseFee(shipment, tariff) { const tier = tariff.tiers.find((t) => shipment.weightGrams <= t.maxWeightGrams) ?? tariff.tiers.at(-1); return Math.max(tariff.minFee, Math.round(tier.fee * tariff.zoneFactor[shipment.zone])); }
What varies is the rules: which discount applies to which shipment. That decision can be made in three separate places.
Three Ways to Write the Same Behavior
The first way leaves the decision to a flag parameter. The caller states which rule is active with booleans, and the rule itself lives in the core’s body.
// flag.mjs — which rule applies is chosen with flag parameters import { baseFee } from "./tariff.mjs"; export function calculateFeeFlagged(shipment, tariff, options = {}) { let fee = baseFee(shipment, tariff); if (options.contracted) fee = Math.round(fee * 0.88); if (options.volume) fee = Math.round(fee * 0.95); return fee; }
The second way gathers the rules in a lookup table. The core no longer carries a separate branch for each rule; it looks up the name in the table and applies whatever rate it finds.
// table.mjs — rules are held in a name-rate table import { baseFee } from "./tariff.mjs"; const RATES = { contracted: 0.88, volume: 0.95, }; export function calculateFeeFromTable(shipment, tariff, names = []) { let fee = baseFee(shipment, tariff); for (const name of names) fee = Math.round(fee * (RATES[name] ?? 1)); return fee; }
The third way turns the rule not into a value but directly into a function, taken as a parameter.
// rule.mjs — a rule is a function, passed to the caller as a parameter import { baseFee } from "./tariff.mjs"; export const contracted = (fee) => Math.round(fee * 0.88); export const volume = (fee) => Math.round(fee * 0.95); export function calculateFeeWithRules(shipment, tariff, rules = []) { return rules.reduce((fee, rule) => rule(fee, shipment), baseFee(shipment, tariff)); }
calculateFeeWithRules in the third version is a higher-order function: as
defined in the Programming Fundamentals course, a function that either takes a
function as a parameter or returns a function. It appears twice here — both
calculateFeeWithRules takes the rule list as a parameter, and the reduce inside its
body calls the combining function it is given.
The difference is not formal. In the flagged version, what happens is written in the core; the caller only states which one is turned on. In the table version, what happens still lives inside the library, but it is gathered into a single data structure. In the rule version, what happens sits outside the core; the core only knows how to apply things in sequence.
When a New Rule Is Added
The requirement is this: shipments selected for night delivery get a fixed surcharge — 1500 cents for shipments under 5 kilograms, 2500 cents at or above it. This rule is not a multiplier; it is an added amount, and it looks at the shipment’s weight.
// flag2.mjs — the night-delivery surcharge added to the flagged version import { baseFee } from "./tariff.mjs"; export function calculateFeeFlagged(shipment, tariff, options = {}) { let fee = baseFee(shipment, tariff); if (options.contracted) fee = Math.round(fee * 0.88); if (options.volume) fee = Math.round(fee * 0.95); if (options.night) fee += shipment.weightGrams > 5000 ? 2500 : 1500; return fee; }
// table2.mjs — the night-delivery surcharge added to the table-driven version import { baseFee } from "./tariff.mjs"; const RATES = { contracted: { rate: 0.88, add: 0, heavyAdd: 0 }, volume: { rate: 0.95, add: 0, heavyAdd: 0 }, night: { rate: 1, add: 1500, heavyAdd: 2500 }, }; export function calculateFeeFromTable(shipment, tariff, names = []) { let fee = baseFee(shipment, tariff); for (const name of names) { const r = RATES[name] ?? { rate: 1, add: 0, heavyAdd: 0 }; fee = Math.round(fee * r.rate); fee += shipment.weightGrams > 5000 ? r.heavyAdd : r.add; } return fee; }
// rule2.mjs — the night-delivery surcharge added to the rule-driven version import { baseFee } from "./tariff.mjs"; export const contracted = (fee) => Math.round(fee * 0.88); export const volume = (fee) => Math.round(fee * 0.95); export const night = (fee, g) => fee + (g.weightGrams > 5000 ? 2500 : 1500); export function calculateFeeWithRules(shipment, tariff, rules = []) { return rules.reduce((fee, rule) => rule(fee, shipment), baseFee(shipment, tariff)); }
That all three versions produce the same result is checked by running them. The measurement file lines up six calls side by side: first the old three versions, then the three versions with the night surcharge added.
// measure.mjs — do the three versions agree, and what changes when the new rule is added import { TARIFF, SHIPMENTS } from "./tariff.mjs"; import { calculateFeeFlagged } from "./flag.mjs"; import { calculateFeeFromTable } from "./table.mjs"; import { calculateFeeWithRules, contracted, volume } from "./rule.mjs"; import { calculateFeeFlagged as flagWithNight } from "./flag2.mjs"; import { calculateFeeFromTable as tableWithNight } from "./table2.mjs"; import { calculateFeeWithRules as rulesWithNight, night } from "./rule2.mjs"; const allFees = (f) => SHIPMENTS.map(f).join(", "); const line = (name, value) => console.log(`${name.padEnd(20)} ${value}`); line("flag / before", allFees((g) => calculateFeeFlagged(g, TARIFF, { contracted: true, volume: true }))); line("table / before", allFees((g) => calculateFeeFromTable(g, TARIFF, ["contracted", "volume"]))); line("rules / before", allFees((g) => calculateFeeWithRules(g, TARIFF, [contracted, volume]))); const options = { contracted: true, volume: true, night: true }; const names = ["contracted", "volume", "night"]; line("flag / with night", allFees((g) => flagWithNight(g, TARIFF, options))); line("table / with night", allFees((g) => tableWithNight(g, TARIFF, names))); line("rules / with night", allFees((g) => rulesWithNight(g, TARIFF, [contracted, volume, night]))); // A rule that concerns only one caller: no file in the library changes. const firstShipment = (fee, g) => (g.code === "TR-4471" ? Math.round(fee * 0.5) : fee); line("rules / from caller", allFees((g) => rulesWithNight(g, TARIFF, [contracted, volume, night, firstShipment])));
flag / before 4171, 6239, 12302 table / before 4171, 6239, 12302 rules / before 4171, 6239, 12302 flag / with night 5671, 7739, 14802 table / with night 5671, 7739, 14802 rules / with night 5671, 7739, 14802 rules / from caller 2836, 7739, 14802
The first six lines show that the three versions are behaviorally equivalent. The
last line shows something that only the rule-driven version can write: a discount that
concerns only the shipment coded TR-4471 was defined and passed in with two lines in
the measurement file. No file in the library changed. In the flagged or table version,
the same rule would have required writing a condition specific to a single customer
into the library itself.
Counting the Lines Touched
While adding the night surcharge, diff counts how much moved in each version. Two
separate measures are kept: lines touched across the whole file, and lines touched in
the core — that is, in the fee function itself that gets exported. A third measure
is the number of decision points accumulated in the core.
# Core: the portion of the file from the first `export function` line to the end of the file. # The file-name comment on the first line is not counted in the measurement. core() { awk '/^export function/,0' "$1"; } decisions() { core "$1" | grep -oE '\bif\b|\bfor\b|\?\?|\?' | wc -l | tr -d ' '; } for s in flag table rule; do total=$(diff <(tail -n +2 "$s.mjs") <(tail -n +2 "${s}2.mjs") | grep -c '^[<>]') core_touched=$(diff <(core "$s.mjs") <(core "${s}2.mjs") | grep -c '^[<>]') printf '%-7s touched: %2d in core: %d decision points: %s -> %s\n' \ "$s" "$total" "$core_touched" "$(decisions "$s.mjs")" "$(decisions "${s}2.mjs")" done
flag touched: 1 in core: 1 decision points: 2 -> 4 table touched: 11 in core: 6 decision points: 2 -> 3 rule touched: 1 in core: 0 decision points: 0 -> 0
The total line count can be misleading: both the flagged version and the rule-driven version grew by a single line. The distinction is in the second column. In the flagged version that line entered the core’s body; decision points went from two to four, and every new rule keeps raising that count. In the rule-driven version the core did not change at all; the added line is an independent function outside the core, and the core’s decision-point count stays at zero.
The table-driven version is the most expensive: eleven lines touched, six of them in
the core’s body. The reason is the table’s schema. Because the table can only hold
a rate, when an added-amount rule arrives, two more fields had to be added to every
row — even though contracted and volume never use those fields. A lookup table is
cheap as long as everything it holds shares the same shape; when a new rule brings a
new shape, the cost spreads across the entire table.
A Function That Returns a Function
The second form of higher order is returning a function. Because all the rules derive from a handful of patterns, generating functions can be defined instead of writing the rules by hand.
// generator.mjs — a function that returns a function: the second face of higher order import { TARIFF, SHIPMENTS } from "./tariff.mjs"; import { calculateFeeWithRules } from "./rule2.mjs"; export const rated = (rate) => (fee) => Math.round(fee * rate); export const fixedAdd = (cents) => (fee) => fee + cents; export const when = (condition, rule) => (fee, g) => (condition(g) ? rule(fee, g) : fee); const heavy = (g) => g.weightGrams > 5000; const rules = [ rated(0.88), rated(0.95), when(heavy, fixedAdd(2500)), when((g) => !heavy(g), fixedAdd(1500)), ]; console.log(SHIPMENTS.map((g) => calculateFeeWithRules(g, TARIFF, rules)).join(", "));
5671, 7739, 14802
The result is identical to the values the three hand-written rules produced. What
differs is how the rules are produced: an unbounded number of rules can be built from
three three-line generators — rated, fixedAdd, and when. when additionally
wraps one rule inside another; it decides whether to call the rule it received. Rules
being stackable on top of one another this way is the subject of the next lesson.
The Cost of Indirection
A function parameter is not free. The answer to “what happens to a contracted
customer’s shipment” sits in a single file in the flagged version: flag2.mjs is
read, three branches are seen, the question is answered. In the rule-driven version,
the answer is spread across two files — the rule’s definition is in rule2.mjs, which
rules apply in which order is in measure.mjs. The number of files that must be read
goes from one to two, and as call sites multiply, the question of where the order is
set gets asked separately at every call site.
The criterion, then, is the number and source of the rules. If the rule set is closed and short — two or three fixed options, all of it the library’s own knowledge — a flag does the same job with less indirection. If the rule set is open, meaning rules the library cannot know in advance will come from callers, a function parameter is the only version that can accommodate this without ever changing the core.
Summary
- A higher-order function either takes a function as a parameter or returns a
function;
calculateFeeWithRulesshows the first form,ratedshows the second. - When the same behavior is written three ways, the results are identical: 4171, 6239, and 12302 for the three shipments.
- Adding the night surcharge changes 1 line in the flagged version’s core and raises its decision points from 2 to 4; the rule-driven version’s core changes by 0 lines and its decision points stay at 0.
- The lookup table is the most expensive version when a new rule does not fit its schema: 11 lines touched, 6 of them in the core.
- A rule that concerns only one caller is defined and passed in with two lines in the rule-driven version, and no file in the library changes.
- The cost of a function parameter is indirection: the number of files that must be read to understand what happens to a shipment goes from one to two.
Next Step
This lesson’s rules were applied one at a time; reduce called each of them in turn,
and the intermediate results were never named anywhere. As the number of rules grows,
the real question becomes the order itself: whether the discount runs before or after
the surcharge, and where the minimum-fee floor is applied, changes the result. The next
lesson builds this sequential application as a pipeline: the steps are named, each step
is tested on its own, and when two steps swap places, how many shipments’ fees shift by
how many cents is counted.
To keep your progress and take notes, Log in
My notes
Log in to take notes.