Boolean Parameters
sendEmail(user, true, false) is unreadable at the call site, and the call site is where every future reader meets it. The fix is a type, not a comment.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
What is actually wrong with a boolean parameter, and what should replace it?
A reviewer reads sendEmail(user, true, false) in a pull request and cannot tell whether the email is being sent to the admin, or suppressed, or queued. The signature is two scrolls away in another file.
Add a parameter with a default. sendEmail(user, copyToAdmin = false) does not break any existing caller, it is one line, and the name is right there in the signature.
The name is in the signature and the call site has true. Every reader of the call site — which is every reader, because that is where they land from a stack trace — sees a bare literal with no name attached to it.
- The name is in the signature and the call site has
true. Every reader of the call site — which is every reader, because that is where they land from a stack trace — sees a bare literal with no name attached to it. - The second flag arrives and the parameters become positional trivia.
sendEmail(user, true, false)andsendEmail(user, false, true)are one keystroke apart, mean opposite things, and no test distinguishes them unless someone thought to write one. - The body forks. Two booleans mean four paths, three mean eight, and the function that was "send an email" is now a small interpreter for a configuration language nobody designed (Boolean Flag Explosion).
- Then the third value arrives — digest instead of immediate — and there is nowhere to put it. A boolean has exactly two states, so the requirement becomes another boolean, and now
immediate=false, digest=falseis a state that means nothing and is reachable.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The function has forty existing call sites across three modules, so any fix has to be introducible incrementally.
- The language has no named arguments (TypeScript, Java, Go), which is why the problem exists at all — in Python or Kotlin,
send_email(user, copy_admin=True)already solves most of it. - The flags exist because two features genuinely needed different behaviour; the requirement is real even though the interface is not.
- A call site must be readable without opening the callee. That is the property being defended, and every other argument here is downstream of it.
- Adding a third behaviour must not require a new boolean, because the number of combinations doubles each time and most of them are meaningless.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The signature owns making the call site legible. A comment at the call site is a workaround that the next caller will not copy.
- The type owns the set of legal combinations. If two flags cannot both be true, that is the type's job to prevent, not the body's job to check (Making Illegal States Unrepresentable).
- Whoever adds the second flag owns noticing that the parameter list has become a configuration object and doing something about it then, rather than at flag five.
- The boundary is the call site, not the function. Everything about this lesson follows from asking what a reader sees there with no other file open.
- A flag that selects between two genuinely different behaviours belongs at a different boundary entirely: two functions, because the caller already knows statically which one it wants (Designing a Module Interface).
- A flag that is real runtime configuration — a dry run, a feature flag, a verbosity level — belongs in an options value that can be passed through and logged as a unit.
The damage is at the call site
It is worth being precise about where the cost lands, because it explains why every fix that stays in the callee fails. The signature has names. The call site does not, and the call site is what a reader sees when they arrive from a stack trace, a git blame or a search.
That asymmetry is why "the parameter is well named" is not a defence, and why a comment at one call site does not help the other thirty-nine.
sendEmail(user, true, false) sendEmail(user, false, true) sendEmail(user, true, true) // What does the third one do? To answer, open another file. // Is the last combination even legal? Nothing says.
sendEmail(user, DeliveryMode.Immediate)
sendEmail(user, DeliveryMode.Digest)
sendEmail(user, DeliveryMode.Immediate, { copyToAdmin: true })
// Readable with no other file open, and there is no
// combination to wonder about: the mode is one of three.The reader's question is answered where the reader is standing. Just as importantly, the illegal fourth combination stopped existing — under booleans it was reachable and someone eventually reached it, and the body handled it by accident.
What happens when the third value arrives
The strongest argument against a boolean is not readability. It is that a boolean is a closed set of exactly two, and domain concepts are almost never permanently binary.
Watch what a new requirement does to each shape. Under a boolean, the new state has to be encoded as a combination, which means states exist that nobody designed.
1// v12function sendEmail(u: User, immediate: boolean) {}3 4// v2: "add a daily digest"5function sendEmail(u: User, immediate: boolean, digest: boolean) {}6// immediate=true, digest=true -> ?7// immediate=false, digest=false -> ?8// Two of four states are undefined and reachable.9 10// The shape that had room:11type DeliveryMode = 'immediate' | 'digest' | 'suppressed'12function sendEmailV2(u: User, mode: DeliveryMode) {13 switch (mode) {14 case 'immediate': return sendNow(u)15 case 'digest': return queueForDigest(u)16 case 'suppressed': return17 } // exhaustive: adding a case is a compile error here18}The undefined states in the middle are not hypothetical — they are what a caller produces when it sets one flag and forgets the other. The union does not merely read better; it has a different number of states, and that is the substantive difference.
Which replacement, and what each costs
There are four reasonable answers and they are not interchangeable. The selector is whether the caller knows the value statically, whether the flag is a domain concept, and whether flags interact.
The scores below are relative and rough. They exist to show the shape of the trade, not to be added up.
| Option | Simplicity | Flexibility | Testability | Migration cost | Note |
|---|---|---|---|---|---|
| Leave the booleans | Nothing to do today. Testability is the score that matters: the combination space grows faster than anyone writes tests for it. | ||||
| Options object | Cheapest real fix and easy to introduce alongside the old signature. Does nothing about illegal combinations unless the type forbids them. | ||||
| Two functions | Simplest bodies and the easiest tests, because each function has one path. Wrong when the caller decides at runtime — then the if just moves outward and multiplies. | ||||
| Domain enum / union | The only option with room for the third value. Costs a shared type and a branch in every consumer, which is a distributed change each time a case is added. |
caveat These scores say nothing about the size of the body behind the flag. A two-line function with a boolean is not worth touching, and a two-hundred-line one with three flags is worth restructuring even if the call sites read fine — the numbers here rate the interface, and the interface is only half the problem.
How to build it
Most important first.
- If the caller always knows the value at the call site as a literal, split the function.
sendEmail(user)andsendEmailWithAdminCopy(user)need no flag, and each body is simpler than the fork it replaces. - If the flag is genuine runtime configuration, take an options object with named fields:
sendEmail(user, { copyToAdmin: true, dryRun: false }). This costs one allocation and buys every call site a name. - If the flag is a domain concept, give it a domain type —
DeliveryMode.Immediaterather thanimmediate: true— because the third value is coming and an enum has room for it (Enum Evolution: The New Value That Broke Old Clients). - If two flags interact, replace both with one type whose cases are the legal combinations. Four booleans with three legal states is a type with three cases, and the type is smaller (Making Illegal States Unrepresentable).
- A boolean is fine when the argument is bound to a well-named variable at the call site:
render(isDarkMode)reads correctly and needs no ceremony. The problem is the literal, not the type. - Migrate by adding the new signature alongside the old, deprecating the old, and moving call sites in reviewable batches — never by changing forty call sites in one commit (Deprecation).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Adding a third delivery mode under booleans costs a new parameter, an audit of every call site to decide what the new flag should be there, and a body that now has eight paths of which three are meaningful.
- The same change under an enum costs one new case and a compiler-produced list of the places that must handle it. That difference — a search you perform versus a list you are handed — is the entire argument.
- The change after that is where the divergence compounds: under booleans, each new flag multiplies the untested combination space; under a closed type, each new case adds one branch.
- Two functions instead of one means shared logic must be factored out deliberately, and the module now exports two names where it exported one — a wider interface for a narrower job.
- Options objects cost an allocation and a type declaration, and in a hot path called millions of times that is occasionally a real cost worth measuring rather than assuming (Allocation and Copies).
- Enums move the cost to consumers: every addition is a distributed change. That is the trade you are making — visibility of change against ease of change.
What can go wrong
- The options object becomes a bag: fifteen optional fields, most combinations untested, and the function is exactly as forked as before with nicer syntax at the call site.
- The split produces two functions that share ninety percent of a body by copy-paste, so the next change has to be made twice (Duplicate Knowledge).
- The enum is introduced but the old boolean parameter is kept "for compatibility", so both exist and can disagree.
- A comment is used instead —
sendEmail(user, /* copyToAdmin */ true)— which works, is not enforced by anything, and is wrong the day the parameter order changes.
- An options object becomes a shared type between caller and callee, and every field added to it is a change both sides can see — which is the point, and also a coupling to declare deliberately.
- Splitting into two functions removes the runtime dependency on the flag entirely: the choice moves to compile time, and the caller depends only on the function it actually uses.
- An enum couples every caller to the set of cases, so adding a case is a change every exhaustive
switchmust acknowledge. In a language with exhaustiveness checking that is a feature; in one without, it is a silent default branch (Error Boundaries).
- "Booleans are banned." No. A boolean parameter bound to a named variable at the call site is fine, and a
dryRunfield in an options object is a boolean doing exactly the right job. - "Just use named arguments." Correct where the language has them, and they solve the legibility half. They do not solve the forked body or the meaningless combination, which is the more expensive half (Boolean Flag Explosion).
- "Split into two functions" always. If the caller decides at runtime, splitting just moves an
ifto every call site and duplicates it forty times. - "This is trivial style." The specific failure — a function with three flags, eight paths and two tests — is one of the more common places production bugs actually live, because the untested combinations are invisible in coverage numbers.
- long-parameter-list
- primitive-obsession
Testing it, and how it ages
- Test the combinations that are legal, and assert that the illegal ones cannot be constructed. If they can only be prevented at runtime, that check needs its own test (Making Illegal States Unrepresentable).
- Under booleans, the honest test count is the product of the flags, and nobody writes it — so measure the gap by counting combinations rather than assuming coverage.
- A test that reads
sendEmail(user, true, false)has the same legibility problem as production code, and tests are read more often when something is already broken.
- Boolean parameters almost never stay at one. The realistic lifecycle is one flag, then two, then a body nobody wants to touch, and the cheapest intervention point is the second flag.
- Options objects age well until they stop being cohesive — when half the fields are meaningless for a given call, the object is really two objects (Cohesion).
- An enum ages best and constrains most: it is easy to add a case and hard to remove one, because every consumer has a branch for it (Deprecation).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- LANGUAGE-SPECIFICIn Python, Kotlin, Swift and C# the call site can name the argument, which removes most of the legibility problem for free — Python can even force it with keyword-only parameters. In TypeScript, Java and Go there is no such facility, so the options object or the split is doing work the language does not do, and the advice is correspondingly stronger there.
- GENERALThe combinatorial half of the argument — n flags means 2^n paths of which most are meaningless and untested — is a property of the interface rather than the language, and holds even where named arguments make the call site perfectly readable.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the untested combination space behind a flagged function is invisible to line coverage, which is a case where a coverage number and actual confidence diverge sharply.