Control flow
WCL has no statements, no loops and no early return. It has expressions, and an expression always produces exactly one value. Control flow is therefore not a way to do something conditionally — it is a way to choose a value.
There are five forms, plus one operator:
- if / else — choose between two values on a boolean.
- match — choose among many values by testing a pattern.
- if let — bind and choose in one step, when only one pattern interests you.
- { … } — a block expression: name intermediate values, then produce one.
- try / catch — produce a fallback value when evaluation fails.
- ?? — produce a fallback value when the left side is none.
Every file and every command below was run before it was written down. Type them and compare.
§ 1One file, every form
Save this as release.wcl. Each computed field on the service uses a different form.
# release.wcl — one service, and what the release plan says to do with it.
symbol_set Env { dev staging prod }
union Rollout {
AllAtOnce none
Canary { percent: u32 }
BlueGreen { window_minutes: u32 }
}
@block("service")
type Service {
@inline(0) name: identifier
env: Env
replicas: u32
rollout: Rollout
tagline: utf8?
# Every field below is computed by a control-flow expression.
tier: utf8
plan: utf8
subtitle: utf8
budget: f64
}
@document
type Cfg {
@children("service") services: list<Service>
}
service web {
env = :prod
replicas = 12u32
rollout = Rollout::Canary { percent: 10u32 }
# if / else if / else — a multi-way branch on a number.
tier = if self.replicas > 10u32 { "large" }
else if self.replicas > 3u32 { "medium" }
else { "small" }
# match — one arm per variant, destructuring each payload.
plan = match self.rollout {
Rollout::Canary { percent } if percent < 25u32 => $"canary % first",
Rollout::Canary { percent } => $"canary % at once",
Rollout::BlueGreen { window_minutes } => $"blue/green over m",
Rollout::AllAtOnce => "all at once",
_ => "unknown rollout",
}
# ?? — a fallback for a value that may be absent.
subtitle = self.tagline ?? "no tagline"
# A block expression names the steps of one calculation.
budget = {
let per_replica = 0.25;
let multiplier = if self.env == :prod { 4.0 } else { 1.0 };
per_replica * multiplier * self.replicas
}
}
Read the four computed fields back:
$ wcl check release.wcl
OK
$ wcl get release.wcl services.web.tier
"large"
$ wcl get release.wcl services.web.plan
"canary 10% first"
$ wcl get release.wcl services.web.subtitle
"no tagline"
$ wcl get release.wcl services.web.budget
12.0
Now edit the rollout field and read plan again. Nothing else in the file moves. Change the line to rollout = Rollout::AllAtOnce:
$ wcl get release.wcl services.web.plan
"all at once"
Then to rollout = Rollout::BlueGreen { window_minutes: 30u32 }:
$ wcl get release.wcl services.web.plan
"blue/green over 30m"
The rest of this chapter takes them one at a time.
§ 2if and else
An if takes a condition and a block expression, with an optional else. The braces are part of the grammar: if x { … }, never if x then ….
tier = if self.replicas > 10u32 { "large" }
else if self.replicas > 3u32 { "medium" }
else { "small" }
else if chains as far as you like. Each arm is tested in source order, and the first true one wins. Every arm is an ordinary block expression, so it may hold let bindings of its own — see Block expressions.
The condition must be a bool. There is no truthiness — a number, a string and none are all refused. The diagnostic borrows its wording from &&, which shares the same check, so read past the operator name to the type:
$ cat truthy.wcl
@document
type Cfg { a: utf8 }
a = if 1 { "x" } else { "y" }
$ wcl get truthy.wcl a
wcl::eval::type_mismatch
× operator '&&' is not defined for i64 and —
The branches are not checked against each other
if cond { 1 } else { "x" } is legal. Only the branch that runs produces a value, and only that value meets the field's declared type. With cond false the field gets "x" and wcl check prints OK. Flip cond to true and the same file fails with field 'a' declared as utf8 but value is i64. A branch you never take is a branch nothing checks. Write both branches at one type yourself.
§ 2.1A branchless if is none
The else is optional. Leave it out and the untaken branch is none, so an else-less if has type T?.
# A conditional list element.
classes = ["nav", if entry.current { "current" }]
# A chain with no final else — `none` when no arm is taken.
badge = if count == 0u32 { "empty" } else if count > 99u32 { "many" }
# An optional field, without the `else { none }` ceremony.
subtitle = if page.tagline != "" { page.tagline }
That none is a value, and it has to land somewhere that accepts absence. A field declared subtitle: utf8? accepts it. A list element accepts it, and the none stays in the list rather than disappearing:
$ cat classes.wcl
@document
type Cfg { classes: list<utf8> }
classes = ["nav", if false { "cur" }]
$ wcl check classes.wcl
OK
$ wcl get classes.wcl classes
["nav", none]
A required field does not accept it. Assign an else-less if to title: utf8 and the file fails whenever the condition is false:
$ cat title.wcl
@document
type Cfg { title: utf8 }
title = if false { "yes" }
$ wcl check title.wcl
wcl::eval::schema_violation
× field 'title' declared as utf8 but value is none
title.wcl: 1 schema violation
Only the plain if may drop its else. if let may not.
§ 3match
A match tests one value — the scrutinee — against a list of patterns. The first arm whose pattern fits wins, in source order, and its body is the value of the whole expression.
plan = match self.rollout {
Rollout::Canary { percent } if percent < 25u32 => $"canary % first",
Rollout::Canary { percent } => $"canary % at once",
Rollout::BlueGreen { window_minutes } => $"blue/green over m",
Rollout::AllAtOnce => "all at once",
_ => "unknown rollout",
}
Each arm is pattern => expression, and arms are separated by commas. The trailing comma is optional. A pattern that binds names — percent, window_minutes — makes them available in that arm's body and nowhere else. Patterns below is the full vocabulary.
An arm body is any expression, so a block, a nested match or an if all fit:
label = match n {
2 => { let x = "two"; to_upper(x) },
_ => "other",
}
§ 3.1Guards
An if expr between the pattern and the => is a guard. The arm fires only when the pattern fits and the guard is true. A pattern that fits with a false guard does not stop the search. WCL goes on to the next arm, which is how the two Rollout::Canary arms above divide one variant between them.
size = match n {
k if k < 0 => :negative,
0 => :zero,
k if k > 1000 => :huge,
_ => :ordinary,
}
A guard must evaluate to a bool. Anything else fails with match guard must return bool, got i64, naming the type it got.
§ 3.2The last arm must catch everything
WCL does not compute exhaustiveness from the scrutinee's type. It enforces a structural rule instead, at parse time: the final arm must be a single wildcard or binding pattern, with no alternation and no guard. Anything else is a parse error, not a runtime surprise.
$ cat partial.wcl
@document
type Cfg { a: utf8 }
let n = 1
a = match n {
0 => "zero",
1 => "one",
}
$ wcl check partial.wcl
wcl::parse
× match must end with a wildcard or binding arm
╭─[partial.wcl:6:3]
5 │ 0 => "zero",
6 │ 1 => "one",
· ─────┬────
· ╰── this arm is refutable; add `_ => …` at the end
7 │ }
╰────
A guard on the final arm is refused for the same reason, because a guard can fail:
$ cat guarded.wcl
@document
type Cfg { a: utf8 }
let n = 1
a = match n {
0 => "zero",
k if k > 0 => "pos",
}
$ wcl check guarded.wcl
wcl::parse
× match must end with a wildcard or binding arm with no guard
╭─[guarded.wcl:6:3]
5 │ 0 => "zero",
6 │ k if k > 0 => "pos",
· ─────────┬─────────
· ╰── make the final arm `_ => …` (no alternation, no guard)
7 │ }
╰────
The rule costs one arm and buys a guarantee: a match always produces a value. There is no "no arm matched" outcome you can reach from source, so no match needs a fallback around it.
Cheap, not clever
The trade is deliberate. A type-directed exhaustiveness check would let you drop _ => … once you cover every variant of a union. It would then have to re-check every match in every importing document each time that union grew. The structural rule is decided by the parser alone, so it costs nothing and never changes its mind. The price is a _ arm you may know is unreachable.
§ 4Patterns
One vocabulary serves match and if let:
| Pattern | Matches | Example arm |
|---|---|---|
| _ | Anything. Binds nothing. | _ => "anything" |
| name | Anything, and binds it to name. | n => $"bound ${n}" |
| name @ inner | Whatever inner matches, also bound whole as name. | n @ 7 => $"seven as ${n}" |
| 42, "go", true, :prod | A literal, by equality. | :prod => "production" |
| none | The absent value. | none => "absent" |
| Union::Variant | A unit variant. | Shape::Empty => "empty" |
| Union::Variant(pat) | A variant with a positional payload, matched by pat. | Shape::Square(side) => … |
| Union::Variant { f: pat } | A record variant, field by field. | Shape::Rect { w, h } => … |
| Union::Variant { f, .. } | The same, ignoring the fields you did not name. | Shape::Rect { w, .. } => … |
| Variant { … } | The same, unqualified — the variant is resolved against the value's own union. | Rect { h, .. } => … |
| pat1 | pat2 | Either alternative. | 1 | 3 | 5 => "odd" |
Three details are worth spelling out.
A record pattern names the fields it wants. { w, h } is shorthand for { w: w, h: h } — the field's own name becomes the binding. Write { w: width } to bind it under another name. wcl fmt expands the shorthand back to the long form, so do not be surprised when a formatted file reads { percent: percent }.
Alternatives must bind the same names. 1 | 3 | 5 binds nothing, so it is fine. 0 | k is not, because the arm body would not know whether k exists:
$ cat alt.wcl
@document
type Cfg { a: utf8 }
let n = 1
a = match n {
0 | k => "x",
_ => "y",
}
$ wcl check alt.wcl
wcl::parse
× match alternatives bind different names
╭─[alt.wcl:5:7]
4 │ a = match n {
5 │ 0 | k => "x",
· ┬
· ╰── each `|` alternative must introduce the same bindings
6 │ _ => "y",
╰────
A variant-literal scrutinee needs parentheses. Write the scrutinee as a variant and the brace that should open the match opens the variant's record body instead. The parser then reads your first arm as a record field, and says so:
$ cat lit.wcl
union Shape { Empty none Square f64 }
@document
type Cfg { a: utf8 }
a = match Shape::Empty { Shape::Empty => "empty", _ => "other" }
$ wcl check lit.wcl
wcl::parse
× expected ':' after field name in record, found '::'
╭─[lit.wcl:4:31]
3 │ type Cfg { a: utf8 }
4 │ a = match Shape::Empty { Shape::Empty => "empty", _ => "other" }
· ─┬
· ╰── unexpected token
╰────
Wrap the scrutinee, or put it in a name first. Both of these parse:
a = match (Shape::Empty) { Shape::Empty => "empty", _ => "other" }
let s = Shape::Empty
b = match s { Shape::Empty => "empty", _ => "other" }
An ordinary reference needs no parentheses. match cache { … } and match self.rollout { … } are unambiguous, which is why the rest of this chapter never wraps one.
A record pattern ignores what it does not name
Shape::Rect { w } matches a Rect that also carries an h, and it does so whether or not you write the ... The matcher checks the fields the pattern lists and nothing else. Write the .. anyway: it is the only mark in the source that says the omission was on purpose, and a reader who has to guess will guess wrong.
§ 5if let
An if let is one match arm plus a fallback, written as a conditional. Reach for it when exactly one pattern interests you.
a = if let Cache::Redis { url, .. } = cache { url } else { "not redis" }
The pattern is the full vocabulary from Patterns, the bindings it introduces are in scope in the then block only, and both blocks are ordinary block expressions. else if let chains, and a chain may end in a plain else if or a plain else:
b = if let S::Circle(r) = s { $"circle " }
else if let S::Square { side } = s { $"square " }
else { "neither" }
The else is required — a known gap
A plain if may drop its else; an if let may not. a = if let S::Circle(r) = s { "c" } fails to parse with 'if let' requires an 'else' branch. This is scope, not a semantic obstacle — the evaluator would happily answer none on a no-match, exactly as a branchless if does. Until it is lifted, write else { none } yourself, and give the field an optional type.
An if let also takes no guard. if let P = x if cond { … } is a parse error. Put the test inside the then block, or write a match arm with a guard. That arm is what a guarded if let would have been anyway.
§ 6Which one to reach for
match, if let and ?? overlap, and a great many expressions could be written with any of the three. Save this as choose.wcl and compare them side by side:
# choose.wcl — three ways to handle a value that may not be what you want.
union Cache {
Off none
Memory { size_mb: u32 }
Redis { url: utf8 ttl_s: u32 }
}
@document
type Cfg {
by_coalesce: utf8
by_if_let: utf8
by_match: utf8
}
let tagline = none
let cache = Cache::Redis { url: "redis://localhost", ttl_s: 60u32 }
# `??` — one absent value, one fallback. No pattern, no branch.
by_coalesce = tagline ?? "untitled"
# `if let` — one case is interesting, every other case gets one answer.
by_if_let = if let Cache::Redis { url, .. } = cache { url } else { "not redis" }
# `match` — every case has an answer of its own.
by_match = match cache {
Cache::Off => "no cache",
Cache::Memory { size_mb } => $"MB in process",
Cache::Redis { url, ttl_s } => $" for s",
_ => "unknown cache",
}
$ wcl check choose.wcl
OK
$ wcl get choose.wcl by_coalesce
"untitled"
$ wcl get choose.wcl by_if_let
"redis://localhost"
$ wcl get choose.wcl by_match
"redis://localhost for 60s"
The three are ordered by how much of the value you need to look at.
| Form | Looks at | Binds | Reach for it when |
|---|---|---|---|
| ?? | Whether the value is none | Nothing | One value may be absent and one fallback covers it |
| if let | One pattern, fits or not | That pattern's names | One case has a real answer and the rest share a default |
| match | Every pattern, in order | Each arm's own names | Two or more cases each deserve their own answer |
Two rules of thumb follow. If your match has one interesting arm and a _, an if let says the same thing in one line. If the only question you ask is whether a value is none, ?? says it in half a line.
?? also short-circuits. The fallback never evaluates when the left side is present, and a chain stops at the first value that is not none. Expressions and operators covers where ?? sits in the precedence table.
§ 7Block expressions
A block expression is { } holding zero or more let bindings and then exactly one tail expression. The tail is the block's value.
budget = {
let per_replica = 0.25;
let multiplier = if self.env == :prod { 4.0 } else { 1.0 };
per_replica * multiplier * self.replicas
}
Three rules, all enforced by the parser:
- Every binding ends in a semicolon. The tail does not.
- The tail is mandatory. An empty { } fails with block expression requires a final expression.
- Bindings are ordered. A binding may read the ones above it, and only those.
Blocks appear anywhere an expression does: on the right of a field, as an if arm, as a match arm body, as a function body, and on either side of a try. Use one when a calculation has steps worth naming. The one-expression spelling below produces the same 12.0, and says nothing about what the numbers are:
budget = 0.25 * (if self.env == :prod { 4.0 } else { 1.0 }) * self.replicas
A block never starts with a field name and a colon
{ … } in expression position is also the syntax for a bare record literal. The parser decides by lookahead, and the test is exact: an opening brace followed by an identifier and a : opens a record; anything else opens a block. { let x = 1; x } is a block. { name: "web" } is a record. See Lists, tensors and records.
§ 7.1let bindings and let items
WCL spells two different things let, and they are not the same construct. The let binding described above lives inside a block expression and ends in a semicolon. The let item from Documents, fields and blocks sits at file or block scope and takes no semicolon.
| Form | Written as | Lives in | Terminator | Scope |
|---|---|---|---|---|
| let binding | let name = expr; | A { … } block expression | ; — required | The rest of that block |
| let item | let name = expr | A file, or a block body | None | Sibling and descendant expressions |
Neither is document data, and that is the one thing they do share: wcl get reaches neither, and the schema validates neither. What separates them is reach. A binding is gone at the closing brace:
$ cat scope.wcl
@document
type Cfg { a: u32 b: u32 }
a = { let z = 1u32; z }
b = z
$ wcl check scope.wcl
OK
$ wcl get scope.wcl a
1u32
$ wcl get scope.wcl b
wcl::eval::unresolved_reference
× unresolved reference 'z'
Note that wcl check passed that file. check reports schema violations; an unresolved reference is an evaluation error, and it surfaces when something forces the field. How a document evaluates covers the split.
A binding may shadow an item of the same name, and the innermost one wins:
$ cat shadow.wcl
@document
type Cfg { a: u32 }
let n = 1u32
a = { let n = 2u32; n }
$ wcl get shadow.wcl a
2u32
The rule of thumb: a let item is for a value two fields share; a let binding is for a step inside one field's calculation.
§ 8try and catch
try body catch name => handler evaluates the body. If the body fails, the failure's rendered message binds to name as a utf8, and the handler's value becomes the result. Both sides accept a block, and the handler also accepts the block form without the =>.
# Expression form. `divisor` may be zero; one replica is the fallback.
replicas = try 1000u32 / divisor catch e { 1u32 }
# The message is an ordinary utf8 value.
why = try 100 / 0 catch e => e
# Block on both sides. `catch msg { … }` — no `=>` needed before a block.
report = try {
let n = 1000u32 / divisor;
$"ok: "
} catch msg {
$"failed: "
}
Every evaluation failure is catchable, and the message is the one the CLI would have printed. Save this as try.wcl. Its four fields wrap four ways to fail: a division by zero, a name that is not there, a pair of fields that read each other, and a raised error.
@document
type Cfg {
arithmetic: utf8 missing: utf8 cyclic: utf8 raised: utf8
x: utf8 y: utf8
}
x = y
y = x
arithmetic = try 100 / 0 catch e => e
missing = try nonexistent catch e => e
cyclic = try x catch e => e
raised = try error("boom") catch e => e
$ wcl get try.wcl arithmetic
"operator '/' cannot divide by zero"
$ wcl get try.wcl missing
"unresolved reference 'nonexistent'"
$ wcl get try.wcl cyclic
"cycle while evaluating 'x'"
$ wcl get try.wcl raised
"error: boom"
That last one is the error("boom") builtin. panic and a failed assert render the same way, and try catches all three. The names differ in intent, not in what they do. Builtins covers them.
What try does not catch is a schema violation, because a violation is not an evaluation failure. The body succeeds, produces a value, and the value is then measured against the declared type:
$ cat wrong.wcl
@document
type Cfg { a: u32 }
a = try "not a number" catch e => 0u32
$ wcl check wrong.wcl
wcl::eval::schema_violation
× field 'a' declared as u32 but value is utf8
wrong.wcl: 1 schema violation
A fallback, not a silencer
try makes any evaluation failure recoverable, cycles and upstream field errors included. That reach is the point, and it is also the risk: a try wrapped around a whole calculation turns a real mistake into a plausible default that nothing complains about. Wrap the smallest expression that can fail, and only where the fallback means something. It is a division that may divide by zero, not a document that may be wrong.
The catch binder is a name, not a pattern — there is no catch Error::Parse(e) => …. A failure is a message, and if you need to branch on it, match the string.
§ 9Where to go next
- Expressions and operators — the operator precedence table, ?? among them, and numeric promotion.
- Types — unions and their variants, which is what most match arms are made of.
- Functions — a function body is a block expression, and a fn item is a let item.
- How a document evaluates — laziness, caching, cycle detection, and why wcl check and wcl get disagree about some errors.
- Builtins — error, panic and assert, the three ways to make a try fire.