A boolean parameter looks harmless because it carries only two values. The problem is that those values often control two different behaviours while saying almost nothing at the call site.
Consider set_access(user, true). Does true mean grant access, require approval, make access permanent, or enable logging? A developer has to remember the parameter name or inspect the function before the call becomes clear.
This problem becomes more expensive when the flag selects different validation, side effects, or failure rules. One function then behaves like two operations hidden behind a small parameter.
This article explains how to recognize that design, when to replace a boolean flag with explicit operations, and when a boolean is still the clearest representation of the problem.
Start with the decision the caller is making
The useful mental model is simple: a parameter should usually provide data to one operation, not secretly choose which operation the caller wants.
Suppose an account service exposes this function:
set_access(user_id, enabled)Callers use it like this:
set_access(user_id, true)
set_access(user_id, false)The implementation may be straightforward:
function set_access(user_id, enabled):
if enabled:
record_access_granted(user_id)
send_welcome_message(user_id)
else:
record_access_revoked(user_id)
terminate_sessions(user_id)The boolean is not merely describing a value. It selects between granting access and revoking access. Those operations already have different consequences.
That distinction matters because callers think in intentions: “grant this user access” or “revoke this user’s access.” The API instead asks them to translate that intention into true or false.
Make distinct intentions explicit
If callers are choosing between two meaningful actions, expose those actions directly:
function grant_access(user_id):
record_access_granted(user_id)
send_welcome_message(user_id)
function revoke_access(user_id):
record_access_revoked(user_id)
terminate_sessions(user_id)The calls now explain themselves:
grant_access(user_id)
revoke_access(user_id)Nothing fundamental happened to the program’s capabilities. The important change is where the decision is represented.
With the flag-based API, the caller’s intention is encoded indirectly in a boolean. With explicit operations, the intention appears in the operation name. That reduces the amount of context a reader must recover before understanding the code.
The change also gives each behaviour room to evolve independently. If revocation later requires a reason but granting does not, the API can reflect that difference:
grant_access(user_id)
revoke_access(user_id, reason)A shared boolean function would instead need another parameter whose meaning may apply to only one branch.
The warning sign is behavioural branching
Not every boolean parameter is a problem. A useful diagnostic question is:
Does changing this boolean describe different data, or does it select a different behaviour?
Consider a rendering function:
render_badge(label, visible)If visible is simply a property of the badge being rendered, a boolean may match the domain well. The two values naturally mean visible and not visible.
Now compare:
save_document(document, true)If true means “publish after saving” and false means “save as a draft,” the parameter selects workflows with different meaning. Calls such as these are harder to read because the boolean carries an instruction rather than an obvious fact.
A stronger API might be:
save_draft(document)
publish(document)The exact design depends on the domain. Publishing may internally save first, or it may require an already saved document. The point is not to mechanically split every function that accepts a boolean. It is to model distinct caller intentions as distinct operations when that distinction is real.
Boolean literals make the problem easier to see
Literal booleans at call sites are especially revealing:
create_report(data, false, true)Without the declaration, a reader cannot reliably tell what either value means. Even a single literal can be unclear:
close_account(account, true)Named arguments can improve local readability in languages that support them:
close_account(account, notify_user = true)That may be enough when notify_user is genuinely an option within one coherent operation. The caller is still closing an account; notification only adjusts how that operation is performed.
This gives a practical distinction:
close_account(account, notify_user = true) # one operation with an option
set_access(user, true) # boolean selects the operationThe first boolean modifies a secondary policy. The second encodes the primary intention.
Named arguments solve an identification problem: they tell the reader what the boolean means. They do not solve an abstraction problem when one function represents multiple actions.
Separate operations can still share implementation
A common objection is that splitting the public API will duplicate code. It does not have to.
Suppose both granting and revoking access update the same repository and audit system. Keep the public intentions separate while sharing lower-level mechanics:
function grant_access(user_id):
change_access(user_id, ACCESS_GRANTED)
send_welcome_message(user_id)
function revoke_access(user_id):
change_access(user_id, ACCESS_REVOKED)
terminate_sessions(user_id)
function change_access(user_id, new_state):
repository.update_access(user_id, new_state)
audit.record_access_change(user_id, new_state)The internal helper has a different role from the public operations. It implements a shared mechanism after the caller’s intention is already known.
This is an important boundary. A flag can be acceptable inside a small implementation detail even when it would make a poor public interface. The design cost depends on who must understand and supply the value.
Do not split an API merely to eliminate the word true. Split it when doing so gives callers a more accurate model of the actions they can request.
Prefer meaningful types when there are several named choices
Sometimes the problem is not really binary behaviour. A boolean is being used because the API originally had two modes:
export(document, compressed)Later the system gains more export policies. Adding more booleans creates combinations that are difficult to interpret:
export(document, compressed, encrypted, streaming)If these values represent independent options, a structured options value may be appropriate. If they represent one choice among several modes, use a named type instead:
export(document, mode = ARCHIVE)
export(document, mode = STREAM)A named set of alternatives communicates the dimension being selected and can grow beyond two values without inventing another boolean.
However, an enum or mode value should not become a way to hide unrelated workflows behind one function. If each mode needs substantially different inputs, guarantees, and side effects, separate operations may still describe the design more accurately.
The decision is therefore not “boolean versus enum.” Ask what the caller is choosing. One operation with a genuine mode can use a mode type. Different intentions can use different operations.
Watch for parameters that only matter on one branch
Flag-controlled functions often accumulate parameters that are meaningful only for one value of the flag:
process_order(order, expedited, courier_service)If courier_service is ignored when expedited is false, the signature permits combinations that do not make sense:
process_order(order, false, EXPRESS_COURIER)The implementation now has to decide whether to ignore the courier, reject the call, or accidentally use it.
Separate operations can make the valid combinations clearer:
process_standard_order(order)
process_expedited_order(order, courier_service)This does more than improve naming. It moves a constraint into the shape of the interface: the expedited-only input is requested only by the expedited operation.
That does not guarantee every call is valid. The courier itself may still be unsupported, and the order may be in the wrong state. But the API no longer invites one obvious invalid combination.
Avoid wrappers that leave the confusing API everywhere
A useful refactoring can be incremental. Start by adding intention-revealing operations that delegate to the existing implementation:
function grant_access(user_id):
set_access(user_id, true)
function revoke_access(user_id):
set_access(user_id, false)Then migrate callers to the new operations. Once direct calls to set_access are gone, its branching logic can be split or retained as a private helper if that still makes sense.
The failure mode is to add the wrappers but continue encouraging new code to call the flag-based function. That creates two public ways to express the same actions without reducing ambiguity.
If compatibility requires the old function to remain public, document the explicit operations as the preferred interface and define a deliberate deprecation path where the surrounding system permits one.
Do not turn every boolean into two methods
Mechanical refactoring can make an API worse.
A boolean remains reasonable when all of these are broadly true:
- the concept is naturally binary in the domain;
- the value is data or a secondary option rather than the caller’s primary action;
- its meaning is clear from the call, naming, or language’s named-argument syntax;
- both values belong to the same operation and obey similar input and failure rules.
For example:
subscription.set_auto_renew(enabled)Here enabled can be a legitimate state value. The operation is “set the auto-renew setting,” and the boolean directly represents that setting.
Even then, enable_auto_renew() and disable_auto_renew() may be preferable if callers usually express commands rather than synchronize state. The choice depends on how the interface is used.
There is also a semantic difference between setting state and requesting a transition. A setter may reasonably be idempotent: setting an already enabled option to true changes nothing. A command such as enable_auto_renew() might have the same guarantee, but the API should define it rather than leaving callers to guess.
Check failure behaviour after splitting the API
A refactoring is incomplete if names improve but semantics become inconsistent.
Suppose the original function rejected an unknown user before evaluating the flag. After splitting it, both new operations should preserve that behaviour unless the change is intentional. The same applies to authorization checks, transaction boundaries, audit records, retries, and other observable effects.
Tests should therefore verify behaviour, not merely that each wrapper passes the expected boolean. Useful cases include:
grant valid user -> access granted and welcome sent
revoke valid user -> access revoked and sessions terminated
unknown user -> same defined failure for both operations
revoke already revoked user -> documented resultBoundary cases such as repeated commands deserve explicit decisions. Should revoke_access on an already revoked user succeed without another side effect, return a specific result, or fail? Splitting a function makes the operations clearer, but it does not answer those domain questions automatically.
Use the call site as the final test
When deciding whether to keep a boolean parameter, read realistic calls without looking at the implementation.
Ask three questions:
- Can a developer understand what action this call requests?
- Does the boolean represent a fact or option, or is it translating an intention into
trueandfalse? - Would the two values naturally acquire different inputs, side effects, permissions, or failure rules as the system evolves?
If the call is clear and the boolean models a real binary value, keep the simpler design. If the value hides two meaningful actions, explicit operations usually make the contract easier to read and evolve.
Conclusion
Boolean parameters are not inherently bad. They become costly when true and false are codes for different intentions that callers must remember.
Start from the decision the caller is making. If the caller is choosing between distinct actions, give those actions names. If the caller is supplying a genuine binary property or a secondary option within one coherent operation, a boolean can remain appropriate.
The practical goal is not to remove booleans from APIs. It is to make important decisions visible where developers read and make them: at the call site.