A single wait answers one question: "has this become true yet?" Real workflows often need to
chain several such questions, where each step depends on the previous one and any step might fail
or time out. WaitForIt.with_wait/3 composes waits into a single with-style pipeline.
The shape
with_wait on(
{:ok, token} <- authenticate(user), # one-shot match (no waiting)
{:ok, account} <~ {load_account(token), timeout: 2_000}, # wait, with per-clause options
{:ok, balance} <~ fetch_balance(account) # wait, with global/default options
), interval: 50 do
{:ok, balance}
else
{:error, reason} -> {:error, reason}
still_pending -> {:still_waiting, still_pending}
endIt reads like with/1, with two clause arrows:
<-— an ordinarywithclause: evaluated once. Matches → bind and continue; doesn't match → route toelse.<~— a wait-for-match clause: re-evaluate the right-hand side until it matches the pattern, then bind and continue.
The clauses are wrapped in on(...) (a purely syntactic wrapper — there is no on function), and
global options for every <~ clause go between the wrapper and the block.
How failure and timeout flow
with_wait mirrors with/1:
- If every clause matches (waiting as needed), the
doblock runs and its value is the result. - If a
<-clause doesn't match, control goes toelsewith the non-matching value. - If a
<~clause times out, its last evaluated value flows toelsetoo — a timeout is treated exactly like a non-match. With noelse, that last value becomes the result.
This is what makes composition clean: a stalled step and a wrong-shaped step are handled the same way, in one place.
# If load_account/1 never returns {:ok, _} within 2s, `:still_loading` (its last value) flows
# to the else block.
with_wait on({:ok, account} <~ {load_account(token), timeout: 2_000}) do
account
else
:still_loading -> {:error, :account_timeout}
endOptions
Every <~ clause accepts the usual options — :timeout, :interval (including a
WaitForIt.Backoff function), :pre_wait, and :signal. Per-clause options override the global
options:
with_wait on(
{:ok, a} <~ slow_thing(), # uses the global timeout: 5_000
{:ok, b} <~ {flaky_thing(), timeout: 500} # this one gives up after 500ms
), timeout: 5_000 do
{a, b}
endGuards and the <~ precedence rule
<~ binds more tightly than when and the comparison operators, so guards and comparison-heavy
right-hand sides must be parenthesized:
# Wait until a counter exceeds a threshold:
with_wait on(({:ok, n} when n > 5) <~ read_counter()) do
n
end
# Parenthesize a comparison on the right-hand side:
with_wait on(found <~ (Enum.find(items, &ready?/1) != nil)) do
found
endSimple clauses such as {:ok, x} <~ fetch(id) and {:ok, x} <~ {fetch(id), timeout: 100} need no
parentheses. If a wait is dominated by a single complex condition, case_wait/3 is often clearer.
Raising instead of routing: with_wait!
with_wait!/3 is identical except that a <~ clause that times out raises a
WaitForIt.TimeoutError. An else block is still honored for ordinary <- non-matches.
{:ok, balance} =
with_wait! on(
{:ok, account} <~ load_account(token),
{:ok, balance} <~ fetch_balance(account)
), timeout: 2_000 do
{:ok, balance}
endObservability
Each <~ clause runs as its own wait, so it emits the standard
[:wait_for_it, :wait, :start | :stop | :exception] telemetry events. See the
Telemetry guide.
Previous: Polling vs signaling · Next: Recipes