Lesson 08 / 16
Boolean Flag Parameters
Measuring the cost of parameters that carry a boolean value through testing: comparing, with node --test, the number of tests needed to cover the branches of a single function with two flags against two split functions, the number of branches the flag opens in the body, and the opaque arguments at the call site.
Contents
The previous lesson removed null values from the signature. A similar problem returns in a different shape. The fee library also calculates return shipments, and the two calculations are largely the same: the same tier selection, the same weight, a different multiplier, and a different minimum fee. Adding a boolean value to the signature to carry the difference seems reasonable.
A boolean flag parameter declares in the signature that the function does two different jobs. Its cost shows up in two places: the branches it opens in the body, and the argument at the call site that says nothing. Both can be counted.
The Two-Flag Version
The function takes two flags: whether the shipment is a return, and whether it is insured.
mkdir -p flagged split
// flagged/fee.mjs — the return and insurance decisions are carried by two flags export function calculateFee(shipment, tariff, isReturn, insured) { const tier = tariff.weightTiers.find((t) => shipment.weightKg <= t.maxKg); const base = tier.ratePerKg * shipment.weightKg; const fee = isReturn ? base * 0.6 : base; const insurance = insured && isReturn === false ? shipment.declaredValue * 0.01 : 0; const minimum = isReturn ? tariff.minimumReturnFee : tariff.minimumFee; return Math.max(fee + insurance, minimum); }
The flag appears once in the signature and shows up three more times in the body: in the fee multiplier, in suppressing the insurance, and in choosing the minimum fee.
tail -n +2 flagged/fee.mjs | grep -c isReturn
4
The number shows that the flag is not a single switch. isReturn is not an on-off
toggle; it is a distinction that splits the function’s body in two from end to end. The
second flag depends on the first: insurance applies only to non-return shipments, so the
isReturn === false condition also enters the insurance check.
The Split Version
In the second version, both flags disappear. The isReturn flag is split into two
separate functions; the insured flag was already repeating information present in the
data — a shipment with a declared value of zero also has a zero insurance fee.
// split/fee.mjs — two separate operations, two separate functions function baseFee(shipment, tariff) { const tier = tariff.weightTiers.find((t) => shipment.weightKg <= t.maxKg); return tier.ratePerKg * shipment.weightKg; } export function shipmentFee(shipment, tariff) { const insurance = shipment.declaredValue * 0.01; return Math.max(baseFee(shipment, tariff) + insurance, tariff.minimumFee); } export function returnFee(shipment, tariff) { return Math.max(baseFee(shipment, tariff) * 0.6, tariff.minimumReturnFee); }
The rule “no insurance applies to a return shipment” is no longer a condition; it is the
returnFee function itself. Breaking the rule requires changing the function; in the
flagged version, passing the wrong combination at a single call site was enough.
Measuring Through Testing
Both versions are tested with the same tariff and the same two shipments. The criterion is fixed: every branch is covered by at least one test. In the flagged version, the two flags have four combinations, and each combination must be tested separately for the case where the minimum fee applies and the case where it does not.
// tariff.mjs — the tariff and shipments shared by both test suites export const TARIFF = { weightTiers: [{ maxKg: 1, ratePerKg: 38 }, { maxKg: 5, ratePerKg: 26 }, { maxKg: 10, ratePerKg: 21 }, { maxKg: 30, ratePerKg: 17 }], minimumFee: 52, minimumReturnFee: 40, }; export const LARGE = { weightKg: 4, declaredValue: 2000 }; export const SMALL = { weightKg: 0.5, declaredValue: 0 };
// test-flagged.mjs — four combinations of two flags, each with the minimum-fee boundary import { test } from "node:test"; import assert from "node:assert/strict"; import { calculateFee } from "./flagged/fee.mjs"; import { TARIFF, LARGE, SMALL } from "./tariff.mjs"; test("shipment, uninsured, above minimum", () => { assert.equal(calculateFee(LARGE, TARIFF, false, false), 104); }); test("shipment, uninsured, below minimum", () => { assert.equal(calculateFee(SMALL, TARIFF, false, false), 52); }); test("shipment, insured, above minimum", () => { assert.equal(calculateFee(LARGE, TARIFF, false, true), 124); }); test("shipment, insured, below minimum", () => { assert.equal(calculateFee(SMALL, TARIFF, false, true), 52); }); test("return, uninsured, above minimum", () => { assert.equal(calculateFee(LARGE, TARIFF, true, false), 62.4); }); test("return, uninsured, below minimum", () => { assert.equal(calculateFee(SMALL, TARIFF, true, false), 40); }); test("return, insured, insurance suppressed", () => { assert.equal(calculateFee(LARGE, TARIFF, true, true), 62.4); }); test("return, insured, below minimum", () => { assert.equal(calculateFee(SMALL, TARIFF, true, true), 40); });
In the split version, there is no combination; each function has a single boundary.
// test-split.mjs — two functions, each with the minimum-fee boundary import { test } from "node:test"; import assert from "node:assert/strict"; import { shipmentFee, returnFee } from "./split/fee.mjs"; import { TARIFF, LARGE, SMALL } from "./tariff.mjs"; test("shipment fee, above minimum", () => { assert.equal(shipmentFee(LARGE, TARIFF), 124); }); test("shipment fee, below minimum", () => { assert.equal(shipmentFee(SMALL, TARIFF), 52); }); test("return fee, above minimum", () => { assert.equal(returnFee(LARGE, TARIFF), 62.4); }); test("return fee, below minimum", () => { assert.equal(returnFee(SMALL, TARIFF), 40); });
The count is read from the runner’s own summary. Because the duration fields change from run to run, only the counter lines are kept.
for t in test-flagged.mjs test-split.mjs; do echo "== $t" node --test "$t" 2>&1 | grep -E '(tests|pass|fail) [0-9]+$' done
== test-flagged.mjs ℹ tests 8 ℹ pass 8 ℹ fail 0 == test-split.mjs ℹ tests 4 ℹ pass 4 ℹ fail 0
Eight tests against four. Both versions produce the same four results (104, 124, 62.4, and 40, together with the minimum-fee boundaries); the difference comes from the flag combinations needing to be tested separately.
The second measurement is at the call site. The opaque-argument criterion defined in the
first lesson applies directly to boolean values: a bare true on a call line does not say
which parameter it fills.
for t in test-flagged.mjs test-split.mjs; do echo "$t: $(grep -o 'true\|false' $t | wc -l | tr -d ' ') opaque boolean argument(s)" done
test-flagged.mjs: 16 opaque boolean argument(s) test-split.mjs: 0 opaque boolean argument(s)
Someone reading the line calculateFee(LARGE, TARIFF, true, false) cannot tell whether
the second false turns off insurance or some other option; they have to open the
signature. The line returnFee(LARGE, TARIFF) leaves no question to ask.
When the Number of Flags Grows
Two flags produced four combinations. Adding a third flag raises the combination count to eight; the number of tests grows at the same rate. In the split design, a third job is a third function, and the total grows by one instead of two. The flagged signature multiplies, the split signature adds.
This is the same branching behavior measured in the previous lesson: independent decisions sitting in the same body multiply. A flag parameter carries this multiplication outside the function; what now produces the combinations is not the conditions in the body but the values the caller passes in.
Where a Flag Is Legitimate
Not every boolean parameter is a defect. The distinguishing question is: does the flag change what the function does, or how it does it?
The isReturn flag changed what was being done — there were two separate jobs, and both
were squeezed into one signature. By contrast, a “show the cents” flag passed to a
formatting function determines a detail of the same job; the job is single. Such a
parameter is still opaque at the call site, and the opacity disappears once it is passed
as a named field ({ showCents: true }).
The second criterion is whether the flag overlaps with the data. The insured flag was an
example of this: the declared value already said whether insurance would apply. Keeping
the same information in two places lets the two drift apart — a shipment marked insured
but with a declared value of zero is inconsistent, and the flagged signature accepts that
inconsistency.
Summary
- A boolean flag parameter declares that a function does two jobs; its cost shows up in the branches in the body and the opaque argument at the call site.
- The
isReturnflag appeared once in the signature and in four lines of the file: the fee multiplier, the suppression of insurance, and the choice of minimum fee each depended on it separately. - Covering the branches of the two flags required eight tests; testing the same behavior as two separate functions was completed with four tests.
- Sixteen opaque boolean arguments at the call sites dropped to zero; the split signatures leave no question on the call line.
- As the number of flags grows, combinations multiply while separate functions add; a flag is defensible only when it determines how a job is done rather than what it is, and does not overlap with the data.
Next Step
Signature decisions determine what a function tells the outside world. The same question
can be asked at the module level: how many names does a module expose? In the
split/fee.mjs file, baseFee was not exported; the two fee functions were. This
decision is not arbitrary; every exposed name is an entry added to that module’s
immutable surface. The next lesson measures this surface: how many of a module’s exposed
names are actually used, and how much each exposed name narrows the module’s freedom to
change.
To keep your progress and take notes, Log in
My notes
Log in to take notes.