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:

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.wclwcl
# 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 ${percent}% first",
    Rollout::Canary { percent }                    => $"canary ${percent}% at once",
    Rollout::BlueGreen { window_minutes }          => $"blue/green over ${window_minutes}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:

text
$ 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:

text
$ wcl get release.wcl services.web.plan
"all at once"

Then to rollout = Rollout::BlueGreen { window_minutes: 30u32 }:

text
$ 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 ….

wcl
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:

text
$ 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?.

wcl
# 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:

text
$ 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:

text
$ 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.

wcl
plan = match self.rollout {
  Rollout::Canary { percent } if percent < 25u32 => $"canary ${percent}% first",
  Rollout::Canary { percent }                    => $"canary ${percent}% at once",
  Rollout::BlueGreen { window_minutes }          => $"blue/green over ${window_minutes}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:

wcl
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.

wcl
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.

text
$ 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:

text
$ 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:

PatternMatchesExample arm
_Anything. Binds nothing._ => "anything"
nameAnything, and binds it to name.n => $"bound ${n}"
name @ innerWhatever inner matches, also bound whole as name.n @ 7 => $"seven as ${n}"
42, "go", true, :prodA literal, by equality.:prod => "production"
noneThe absent value.none => "absent"
Union::VariantA 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 | pat2Either 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:

text
$ 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:

text
$ 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:

wcl
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.

wcl
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:

wcl
b = if let S::Circle(r) = s { $"circle ${r}" }
    else if let S::Square { side } = s { $"square ${side}" }
    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.wclwcl
# 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 }   => $"${size_mb}MB in process",
  Cache::Redis { url, ttl_s } => $"${url} for ${ttl_s}s",
  _                           => "unknown cache",
}
text
$ 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.

FormLooks atBindsReach for it when
??Whether the value is noneNothingOne value may be absent and one fallback covers it
if letOne pattern, fits or notThat pattern's namesOne case has a real answer and the rest share a default
matchEvery pattern, in orderEach arm's own namesTwo 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.

wcl
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:

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:

wcl
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.

FormWritten asLives inTerminatorScope
let bindinglet name = expr;A { … } block expression; — requiredThe rest of that block
let itemlet name = exprA file, or a block bodyNoneSibling 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:

text
$ 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:

text
$ 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 =>.

wcl
# 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: ${n}"
} catch msg {
  $"failed: ${msg}"
}

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.

try.wclwcl
@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
text
$ 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:

text
$ 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