Pattern matching
match value {
0 => "zero"
-1 => "negative one"
n when n > 0 => "positive"
_ => "negative"
}
An expression: it evaluates to the matched arm.
What you can match
Literals, including b"\x01" byte literals.
Guards with when, evaluated with the arm's bindings in scope:
match n {
n when n > 0 => "positive"
_ => "zero or negative"
}
Maps and structs, by the subset of fields you name:
match user {
{ role: "admin" } => "admin access"
{ role: "user", active: true } => "active user"
_ => "unknown"
}
Variants, positionally by field order:
type Shape = Circle { r: Int } | Square { side: Int }
match shape {
Circle(r) => 3.14159 * r * r
Square(side) => side * side
}
_ matches anything.
Types from another module
A type declared elsewhere can be written with its module, on both sides:
import "./shapes.ecko"
c = shapes.Circle { r: 2 }
match c {
shapes.Circle { r } => r * r
shapes.Square { s } => s * s
}
The qualifier says where the type was declared, not what it is called, so shapes.Circle { r } and Circle { r } match exactly the same values. Adding or removing the module never changes which arm runs, and ecko fmt keeps whichever you wrote.
A qualified name in a pattern has to be a constructor. shapes.Circle on its own is refused rather than read as a binding - as a binding it would match everything and quietly swallow every arm below it.
The positional form works the same way: shapes.Circle(r) binds by the field order shapes.ecko declared, and so does a bare Circle(r) when exactly one import exports a Circle. If two imports export one with different fields, the bare name is refused - qualify it. A type a module builds but does not export has no field order the importer can see, so a positional pattern on it is an error; export the type, or match by field name with Circle { r }.
Bindings are arm-scoped
A name bound by a pattern exists only inside that arm. It cannot leak into or clobber the surrounding scope, so reusing a familiar name in a pattern is safe.
Exhaustiveness is checked
A match over a declared type that is missing a variant and has no _ is an error, found before the program runs - ecko check reports it and ecko file.ecko refuses to start. Adding a variant and forgetting one of its match sites is found at analysis time, not when the new variant first arrives in production. (Until 0.63 this was a warning.)
A guarded arm does not count as covering its variant, because the guard can fail: B when ready => ... still needs a B arm or a _ after it.
This is the main reason to declare a type rather than passing strings around: a string typo is a runtime surprise, a missing variant arm is a compile-time error.
When no arm matches at run time, the error names the value: Non-exhaustive match: no pattern matched "io". Inside a catch (e) block it also names the error you were handling, (while handling: disk full), so a dispatch on e.kind with a missing arm does not hide the original failure.
Matching tests, it never accesses
A pattern that does not match is not an error - it moves to the next arm. That is the difference from strict access: m.role on a map without role raises, while { role: "admin" } as a pattern simply does not match.
So match is the natural tool for data whose shape you are unsure of, including decoded JSON and model output.
With ai
A typed call returning an enum drops straight into an exhaustive match:
type Sentiment = Positive | Negative | Neutral
match ai[Sentiment] "classify: {review}" {
Positive => thank(review)
Negative => escalate(review)
Neutral => file(review)
}
The model is constrained to the variants and the branches are checked. This is the pattern the language is shaped around - a model call whose result is as trustworthy as a local function's.
Built-in unions
Ok/Err and Some/None are predefined for your own modelling. They are not the error channel - see Error handling.
None declares no fields, so it is a value: result == None is a plain comparison and needs no call. Ok, Err and Some carry a value and are constructors - see Type definitions.