Skip to content
academia.sh

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; calculateFeeWithRules shows the first form, rated shows 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.

Start typing to search.

↑↓ Esc navigate · open · close