Flag Arguments: Why Boolean Parameters Are a Code Smell
A boolean parameter often means a function does two different things wearing one name. Learn to spot flag arguments and refactor them into clearer designs.
Somewhere in most codebases there’s a function like this one, and it’s been quietly accumulating boolean parameters for years:
function sendNotification(
user: User,
message: string,
urgent: boolean,
email: boolean,
markAsRead: boolean
) {
// ...
}
sendNotification(user, "Your invoice is ready", false, true, false);
Reading the call site tells you nothing. What is true here? What does the second false mean? You have to jump to the function signature, count parameter positions, and hold that mapping in your head for the rest of the review. This is the flag argument smell: a boolean parameter that silently selects between two different behaviors inside the same function, disguised as a single option.
Why a Boolean Parameter Is a Design Signal, Not Just a Style Nitpick
The core issue isn’t cosmetic. A boolean parameter is a strong hint that the function is doing two conceptually different things and using an if statement to switch between them internally:
function sendNotification(user: User, message: string, urgent: boolean) {
if (urgent) {
pushToSms(user, message);
pushToEmail(user, message, { priority: "high" });
logUrgentNotification(user, message);
} else {
pushToEmail(user, message, { priority: "normal" });
}
}
This function has two responsibilities wearing one name, and every caller has to know which branch it’s about to trigger — which defeats a large part of the point of extracting a function in the first place. It also composes badly: the moment you need a third variant (“urgent but email-only”), you either add another boolean (now you have two flags and four possible combinations, only some of which are meaningful) or you start threading conditionals through conditionals.
// The combinatorial mess a second flag introduces
function sendNotification(
user: User,
message: string,
urgent: boolean,
smsOnly: boolean
) {
if (urgent && smsOnly) { /* ... */ }
else if (urgent && !smsOnly) { /* ... */ }
else if (!urgent && smsOnly) { /* ... */ }
else { /* ... */ }
}
Four branches for two booleans, and no compiler check that all four combinations are actually valid states — smsOnly with a low-priority push might not even be a case that should exist, but nothing stops a caller from constructing it.
Refactor 1: Split Into Two Functions
The simplest fix, and often the correct one, is to stop pretending this is one function. If the branches genuinely do different things, give them different names:
function sendUrgentNotification(user: User, message: string) {
pushToSms(user, message);
pushToEmail(user, message, { priority: "high" });
logUrgentNotification(user, message);
}
function sendStandardNotification(user: User, message: string) {
pushToEmail(user, message, { priority: "normal" });
}
sendUrgentNotification(user, "Server is down");
sendStandardNotification(user, "Your invoice is ready");
The call site is now self-documenting — you don’t need to know anything about the function’s internals to understand what a call does, because the behavior is in the name, not hidden behind a positional true. This also makes each function individually easier to test and to evolve; adding SMS-specific logic to the urgent path can’t accidentally affect the standard path, because they’re no longer the same function.
Refactor 2: Replace the Flag With an Enum or Union
Sometimes the two branches aren’t really “two functions” — they’re genuinely one operation with a small, closed set of modes, and splitting would just duplicate the shared logic. In that case, replace the boolean with a named type that documents its own meaning:
// Bad: What does `true` mean here? You need the signature to know.
function resize(image: Image, width: number, height: number, maintainAspectRatio: boolean) {}
resize(photo, 800, 600, true);
// Good: Self-documenting at the call site, and extensible without a new boolean
type ResizeMode = "exact" | "preserve-aspect-ratio";
function resize(image: Image, width: number, height: number, mode: ResizeMode) {
if (mode === "preserve-aspect-ratio") {
const ratio = Math.min(width / image.width, height / image.height);
return scale(image, ratio);
}
return stretch(image, width, height);
}
resize(photo, 800, 600, "preserve-aspect-ratio");
The union type buys you two things a boolean can’t give you: the call site reads like a sentence without needing the function definition open in another tab, and the type is open to a third value later ("crop-to-fill") without redefining what true and false mean for every existing caller — a change that, with a boolean, would force you to either overload its meaning or add yet another flag.
TypeScript’s exhaustiveness checking makes this safer too — a switch over a union type warns you at compile time if you add a new mode and forget to handle it, which a chain of if (flag) statements never will:
function resize(image: Image, width: number, height: number, mode: ResizeMode): Image {
switch (mode) {
case "preserve-aspect-ratio": {
const ratio = Math.min(width / image.width, height / image.height);
return scale(image, ratio);
}
case "exact":
return stretch(image, width, height);
default: {
const _exhaustive: never = mode;
throw new Error(`Unhandled resize mode: ${_exhaustive}`);
}
}
}
Refactor 3: Replace Positional Flags With an Options Object
When a function accumulates several optional parameters — not necessarily all booleans, but the kind of signature where remembering the fourth and fifth position by heart is unreasonable — the fix is a named options object rather than a sequence of positional arguments:
// Bad: Positional flags — unreadable at the call site, easy to pass in the wrong order
function createUser(
name: string,
email: string,
isAdmin: boolean,
sendWelcomeEmail: boolean,
requirePasswordReset: boolean
) {}
createUser("Alice", "[email protected]", false, true, false);
// Good: Options object — self-documenting, order-independent, easy to extend
interface CreateUserOptions {
isAdmin?: boolean;
sendWelcomeEmail?: boolean;
requirePasswordReset?: boolean;
}
function createUser(name: string, email: string, options: CreateUserOptions = {}) {
const {
isAdmin = false,
sendWelcomeEmail = true,
requirePasswordReset = false,
} = options;
// ...
}
createUser("Alice", "[email protected]", { sendWelcomeEmail: true });
This doesn’t remove the booleans — isAdmin is still fundamentally a yes/no fact about the user being created, and that’s fine, because not every boolean is a flag argument in the smell sense. The distinction is whether the boolean selects a behavior path inside the function (a flag argument, and a smell) or represents a genuine property of the data (just a boolean field, no different from a name or an email). The options object mainly fixes the readability and ordering problem; it doesn’t by itself fix a function that’s secretly doing two things.
Telling the Two Apart
| Boolean use | Example | Smell? |
|---|---|---|
| Selects an internal code path / algorithm branch | resize(img, w, h, preserveAspect) | Yes — extract functions or use an enum |
| Represents a genuine yes/no fact about the entity | user.isAdmin, order.isPaid | No — this is just a boolean field |
| Toggles a side effect on or off | save(data, { skipValidation: true }) | Borderline — often hides a second, riskier code path worth naming explicitly (saveWithoutValidation) |
| Accumulates with other flags into combinatorial branching | (urgent, smsOnly, silent) | Yes — strong signal to redesign as a mode/state machine |
The skipValidation-style flag deserves a specific callout, because it’s common and specifically dangerous: a boolean that disables a safety check is worse than an ordinary flag argument, because a caller can pass true by accident (a copy-pasted call site, a default that got flipped) and silently skip validation in a case where it mattered. Naming the dangerous path explicitly — saveWithoutValidation() — at minimum makes the risk visible in every caller’s source, rather than buried in a boolean that reads the same as any other.
Applying This Retroactively
You don’t need to refactor every boolean parameter in a legacy codebase in one pass. A practical heuristic: the next time you’re about to add a second boolean parameter to an existing function, stop — that’s almost always the trigger point where positional flags turn into unreadable, undertested combinatorial logic, and the moment to reach for an enum, a split, or an options object instead.
This is also a good lint rule to encode rather than rely on memory for. Several linters can flag boolean literals passed positionally at call sites, which turns “someone will notice in review” into “the build fails until it’s named,” and is usually a cheap way to stop the smell from creeping back in once you’ve paid down the existing instances.
Key Takeaways
- A boolean parameter that changes which code path a function takes is a flag argument — a sign the function is doing two things under one name.
- The call site is the tell:
fn(x, true, false)requires the reader to open the function definition to understand what’s happening. - If the two branches are genuinely different operations, split them into two clearly named functions.
- If it’s one operation with a small closed set of modes, replace the boolean with an enum or union type — you also get compiler-checked exhaustiveness.
- Use an options object to fix unreadable, order-dependent parameter lists, but note it doesn’t by itself fix a function secretly doing two things.
- Not every boolean is a smell — a field that represents a genuine fact about the data (
isAdmin,isPaid) is fine as a plain boolean. - Flags that skip a safety check (
skipValidation) are the riskiest kind — name the risky path explicitly instead of hiding it behind a boolean default.