Beyond the If Pattern
by Laurence Chen
In my work at Gaiwan, there was a piece of code with poor quality that always felt like a thorn in my side. For a long time, I couldn’t come up with a better way to handle it.
The code was a Nested If. Each step-*
is a side-effect operation, and each handler records a log.
(if (step-a ctx)
(if (step-b ctx)
(if (step-c ctx)
(do-something)
(handle-c ctx))
(handle-b ctx))
(handle-a ctx))
Writing code like this is genuinely painful. The pain stems from several causes:
- These
if
forms are tightly packed together. With this layout, if any of the(handle...)
branches get slightly longer, the whole block becomes very long and hard to read. - It’s easy to forget to write a handler branch.
- It’s also not easy to modify later.
Right after writing it, I immediately pulled a colleague aside to discuss: there must be a better way to write this, right? Back then, the first idea that came to mind was using a Monad. Maybe a Monad could make this code easier to read, but considering long-term maintenance, we were worried that the abstraction might become a barrier to understanding, so we didn’t adopt it.
some->
In fact, Clojure already has syntax similar to Monads that can handle this type of problem, so I thought of some->
. In the following example, if any step returns nil, the whole pipeline short-circuits.
(some-> context
step1
step2
step3)
However, this still doesn’t meet my needs, because if any step fails, I need to handle and log it individually. That way, when this code runs in a production environment, I can quickly pinpoint exactly which step the error occurred in.
ok->
I found a feasible solution in an article by Dave Liepmann. In it, he provides a macro: ok->
, which can be considered an enhanced version of some->
.
If a step fails, as long as it writes an :error
, the pipeline short-circuits and the error info is preserved in the context. In other words, I can choose a handler based on the value of :error
.
ok->
is already usable. If there’s anything unsatisfactory about it, it would be the Locality of Behavior—it requires steps and handlers to be written in separate locations.
(defmacro ok->
"Like `some->` but for `:error` keys instead of `nil` values:
When expr does not contain an `:error` key, threads it into the first
form (via ->), and when that result does not contain an `:error` key, through
the next etc"
[expr & forms]
(let [g (gensym)
steps (map (fn [step] `(if (contains? ~g :error)
~g
(-> ~g ~step)))
forms)]
`(let [~g ~expr
~@(interleave (repeat g) (butlast steps))]
~(if (empty? steps)
g
(last steps)))))
ensure->
Inspired by ok->
, I developed ensure->
.
(defmacro ensure->
"Threaded validation pipeline with short-circuiting and error handling.
Takes a context and a series of condition/handler pairs followed by a final
expression to evaluate if all conditions pass.
Each condition is evaluated in order. If the condition returns truthy, the
pipeline proceeds. If it returns falsey, the corresponding handler is invoked
with the original context, and the pipeline terminates immediately.
This is useful when:
- Each step has a specific failure case to handle
- You want linear syntax instead of deeply nested `if`
- You want to maintain clear, stepwise semantics in validations or workflows"
[context & steps]
(let [pairs (partition 2 (butlast steps))
final-fn (last steps)]
(reduce
(fn [acc [step-fn handler-fn]]
`(let [ctx# ~context]
(if (~step-fn ctx#)
~acc
(~handler-fn ctx#))))
`(~final-fn ~context)
(reverse pairs))))
Usage:
(ensure-> ctx
step-a handle-a
step-b handle-b
step-c handle-c
do-something)
This expands to:
(if (step-a ctx)
(if (step-b ctx)
(if (step-c ctx)
(do-something ctx)
(handle-c ctx))
(handle-b ctx))
(handle-a ctx))
The semantics of ensure->
is: “verify a sequence of conditions, and on failure, short-circuit and handle it accordingly.” Since this pattern is commonly used, abstracting it into a reusable macro makes it easier for developers to understand. More importantly, it elevates the semantics from mere “control flow” to “validation and error handling.”
By rewriting the original nested if
using the ensure->
macro, the code becomes easier to read and maintain thanks to the linear structure. It avoids introducing complex abstractions—just simple syntactic sugar. Additionally, the name ensure->
itself clearly communicates the intent of the pattern.
cond not
While using a macro like ensure->
makes the intent explicit, is there a non-macro alternative? In fact, ensure->
can be replaced by the cond not
pattern.
(def ctx ...)
(cond
(not (step-a ctx)) (handle-a ctx)
(not (step-b ctx)) (handle-b ctx)
(not (step-c ctx)) (handle-c ctx)
:else
(do-something ctx))
The if pattern is a code smell
Come to think of it, the nested if
pattern is fairly common. These if
structures often combine into higher-level semantics. But when the only tool for expressing that is if
, the semantics are stuck at the branching control structure level, making the code harder to understand and modify.
Here are some idiomatic alternatives that express richer semantics and avoid overly verbose if
structures.
Nested “conditionally update”
Problem: You want to update a hashmap hm
only when a condition is met.
(if c
(assoc hm :x y)
hm)
Solution:
(cond-> hm
c (assoc :x y))
When there are many such conditions, using cond->
significantly improves readability by converting nested logic into a linear form.
Preventing update-in
from breaking on nil
Problem:
We need to perform an operation on a certain path
inside a HashMap hm
.
- If the path is empty, we insert an empty vector and then insert
val
into it. - If the path already has a vector, we just
conj
the value to it.
The naive way to write it might be:
(if (nil? (get-in hm path))
(assoc-in hm path [val])
(update-in hm path conj val))
Solution:
(update-in hm path (fnil conj []) val)
Enforcing Atom invariants
Problem: An Atom must always hold a positive number.
(def a
(atom 3))
(swap! a (fn [n]
(if (pos? (dec n))
(dec n)
(throw (ex-info "content must be positive"
{:a 1})))))
Solution:
(def a
(atom 3 :validator pos?))
(swap! a dec)
Conclusion
When we write nested if
s layer by layer, it’s often because the language is leading us to express things in a certain way. What we really want to express is: “this is a series of validations—stop on failure, otherwise keep going.” But the language doesn’t give us the best tools to do that.
That’s why the Clojure community has developed many idioms—like cond not
, cond->
, fnil
, :validator
. These aren’t just code-reduction macros; they make your intent clearer.
The ultimate goal of programming has never been just about controlling flow—it’s about designing meaning.
So the next time you’re knee-deep in an if
jungle, stop and ask yourself: What are you really trying to express? Then let the syntax serve your semantics—not the other way around.