Skip to content

Pattern Matching, Maybe, Result & Try

A case expression takes a value and runs the first arm whose pattern matches. The compiler enforces exhaustiveness — miss a variant of an enum and the build fails, not a runtime surprise. We saw the literal-pattern shape in Bindings & Expressions; this chapter goes deeper into patterns, Maybe<'T>, Result<'T, 'E>, and try.

A bare identifier in a pattern position binds the matched value to that name. Add when <cond> to gate an arm on a runtime check:

fn classify(n: Int): String {
  case n {
    0 -> "zero"
    n when n < 0 -> "negative"
    n when n > 100 -> "huge"
    _ -> "regular"
  }
}

fn main(): String {
  dbg classify(0)
  dbg classify(-5)
  dbg classify(500)
  dbg classify(42)
}

The real power of case is taking apart structured values. Reusing the Shape enum from Structs, Enums, Distinct Types:

enum Shape {
  variant Circle Float
  variant Rectangle {width: Float, height: Float}
}

fn area(s: Shape): Float {
  case s {
    Shape.Circle(r) -> r * r * 3.14159
    Shape.Rectangle{width, height} -> width * height
  }
}

fn main(): Float {
  dbg area(Shape.Circle(3.0))
  dbg area(Shape.Rectangle{width: 4.0, height: 5.0})
}

Tuples and structs destructure the same way: (a, b) -> …, Point{x, y} -> ….

The two enums Nomi reaches for whenever an operation can have a “no answer” case. Maybe<'T> is None | Some('T) — for an absence with no further explanation. Result<'T, 'E> is Ok('T) | Err('E) — when failure carries a description. Both types and their bare constructors are in scope by default.

fn lookup(id: Int): Maybe<String> {
  case id {
    1 -> Some("Alice")
    2 -> Some("Bob")
    _ -> None
  }
}

fn name_for(id: Int): String {
  case lookup(id) {
    Some(name) -> name
    None -> "unknown"
  }
}

fn main(): String {
  dbg name_for(1)
  dbg name_for(99)
}

For a small, one-off match, if can bind a pattern directly. The names from the pattern are visible only in the success branch:

fn lookup(id: Int): Maybe<String> {
  case id {
    1 -> Some("Alice")
    _ -> None
  }
}

fn label_for(id: Int): String {
  if Some(name) = lookup(id) {
    name
  } else {
    "unknown"
  }
}

fn main(): String {
  dbg label_for(1)
  dbg label_for(9)
}

When a function returns Maybe<'T> or Result<'T, 'E> and you want to short-circuit on the failure case, prefix the call with try: the wrapped value is unwrapped on success and the whole function returns immediately on failure.

fn safe_div(a: Int, b: Int): Result<Int, String> {
  if b == 0 {
    Err("div by zero")
  } else {
    Ok(a / b)
  }
}

fn calculate(x: Int, y: Int): Result<Int, String> {
  half = try safe_div(x, 2)
  ratio = try safe_div(half, y)
  Ok(ratio + 1)
}

fn main(): Result<Int, String> {
  dbg calculate(100, 5)
  dbg calculate(100, 0)
}

try is lambda-scoped: inside a lambda, try bubbles to the lambda’s own boundary (like return), not the enclosing function. Nomi has no non-local returns.

The next chapter — Interfaces & Dispatch — covers how Nomi composes behavior across types: interface, impl blocks, dispatch resolution, and the universal Debug that’s been powering dbg for every value you’ve seen.