Cases and match
A case is a variant of a type, written Type.Case or .Case and bare only when it is imported. A match is an expression and must cover every case.
A case inside a type declares a variant. match takes a value apart and is an expression, so it produces a value and
has to cover every case. How a case is spelled depends on one thing only: whether the file imports it.
Example#
type Shape {
case Circle(radius: Float)
case Rectangle(width: Float, height: Float)
case Empty
fn area(): Float {
match self {
.Circle(radius) => Float.pi * radius ** 2
.Rectangle(width, height) => width * height
.Empty => 0.0
}
}
}
const shape = Shape.Circle 2.0
print shape.area()Syntax#
case <Name> a case without fields
case <Name>(<field>: <Type>, ...) a case with fields
Type.Case an expression, always legal
.Case an expression where the expected type is known
Case an expression or a pattern, only when the case is imported
match <subject> {
<pattern> [if <guard>] => <expression>
}Rules#
A case is written with its type (
Shape.Circle) or with a leading dot (.Circle) where the type is known. Never bare, unless the file imports it:Circleon its own is a type, a function or a binding of that name, like every other name..Caseworks wherever a type is expected: an annotation, an argument, a field, a result, either side of==, an arm of amatchwhose result type is known, and every pattern (where the type is the type of the value being matched).type Shape { case Circle(radius: Float) case Empty } const unit: Shape = .Circle(1.0) var shapes: List<Shape> = [] shapes.append(.Empty) const other = Shape.Circle 1.0 print "{unit} {shapes} {other}" // prints Circle(radius: 1.0) [Empty] Circle(radius: 1.0)A case is imported by its path, and only a case can be imported through a type:
use Option.Some from "std/core". Withoutfrom, the path is resolved in the file's own scope (use Shape.Circle), which is what the file that declaresShapewrites. There is nouse Option.*.An imported case needs nothing in front of it, in an expression and in a pattern. The prelude imports
Some,None,OkandFail, which is the whole reason those four are written bare:fn describe(value: Int?): String { match value { Some(found) => "there is {found}" None => "nothing" } } print describe(3) print describe(None)In a pattern the first letter decides. A name that starts with a lowercase letter (
_counts as one) binds; a name that starts withAtoZnever binds - it is an imported case or a type read backwards. An uppercase binding is a compile error, so a misspelled case is reported instead of silently becoming a catch-all. The first letter is the same thing at every declaration, because the checker says so there too - see Naming.Does not compilefn describe(value: Int?): String { match value { Nome => "nothing" _ => "something" } } // error: `Nome` is not a case in scopeA binding of an arm that the guard and the body never read is an error. Write
_, or_nameto keep the name as documentation. A lowercase name always binds and never compares, so this is what keeps an arm likelimit =>from quietly being a catch-all.Does not compilefn describe(value: Int?): String { match value { Some(found) => "something" None => "nothing" } } print describe(Some(3)) // error: `found` is never read: write `_`, or `_found` to keep the namematchis an expression and must be exhaustive. There are no open or non-exhaustive types: a public type with cases is a promise, and adding a case is a breaking change the compiler points out at everymatch.Its value has the one type its arms agree on. Where the value is kept without a type that says which one - a binding without an annotation, a
return- arms of two types are an error at the first arm that does not fit; the annotation settles it, and every arm is checked against it. Where nothing keeps the value, thematchis a statement and its arms may be anything, and the same holds for the two branches of anif.Does not compilefn label(value: Int?): String { const shown = match value { Some(found) => found None => "nothing" } "{shown}" } // error: The arms of this `match` do not agree on a type: the first is `Int64`, this one `String`An arm that can never be reached is an error. Like a dead change and a discarded value, an unreachable arm is always a mistake rather than a defensive line.
A pattern mirrors the constructor. A sub-pattern without a label fills the next field from the left, a labeled one names the field it matches, and the labeled ones follow the positional ones - the rule the arguments of a call follow. A pattern that does not name every field ends in
...:type Config { host: String port: Int secure: Bool } fn described(config: Config): String { match config { Config(port: 443, ...) => "the secure port" Config(host, ...) => "over {host}" } } print described(Config("a", 80, false))Directly inside the braces of a
match, a line that starts with.starts an arm. Everywhere else a leading.continues the line above. So the value of an arm that spans a call chain goes into a block:type Shape { case Circle(radius: Float) case Empty } fn describe(shape: Shape): String { match shape { .Circle(radius) => { [radius] .map({ "{_}" }) .joined(separator: "") } .Empty => "empty" } } print describe(Shape.Empty)Every pattern form is available: a literal, alternatives with
|, a range, a binding with a guard, a wildcard, a tuple, a type read backwards, and a list pattern with a rest.fn describe(value: Int): String { match value { 0 => "zero" 1 | 2 | 3 => "small" 4..=9 => "medium" n if n < 0 => "negative" _ => "large" } } print describe(7)Patterns also stand in bindings and conditions:
const Point(x, y) = p,if const Some(user) = findUser(id),while const Some(next) = queue.dequeue(), andfor (key, value) in someMap.if var P = placebinds into the place, exactly like avarparameter, so the subject has to be avarpath and the body runs inside avaraccess to it. Avarpattern that bound a copy would be a dead change by construction.A case that wraps exactly one value of a type no other case of the type wraps generates
From. That is what makes error types cheap:?converts on its own, and nobody writes the conversion.type AppError { case Config(cause: ConfigError) case Io(cause: IoError) case Startup(message: String) }
What this is not#
A bare case name is not a case. This is the single most common mistake for anybody coming from Rust, Swift or Kotlin:
type Shape {
case Circle(radius: Float)
case Empty
}
const shape = Shape.Circle 2.0
const empty: Shape = .Empty
print "{shape} {empty}"type Shape {
case Circle(radius: Float)
case Empty
}
const shape = Circle(2.0)
// error: Cannot find `Circle` hereThe fix is Shape.Circle 2.0, or use Shape.Circle at the top of the file and then Circle 2.0.
A pattern is not resolved by the expected type. What a name in a pattern means hangs on the use at the top of the
file and on its first letter, and on nothing else. That is what keeps renaming a constant from turning an arm into a
catch-all - and the unread-binding rule of rule 6 is what keeps the catch-all from being written by accident in the
first place.
match has no default and no fallthrough. _ is the wildcard, every arm is one arm, and there is no break.
A case with fields is not a class. It has no methods of its own; methods belong to the type, and a method that behaves
differently per case matches on self.
Related#
- Declaring a type - fields, methods and what is generated.
- Cases that stand for numbers -
case Read = 1, and the number of a case both ways. - Result - the case pair every fallible function answers with.
- use - how a case is imported.
- Why a case is never bare - the argument, and the trap it closes.
- Coming from Rust - where
Some(x)works andCircle(x)does not.