Skip to content

Option

Represent absence with Some and None, and use the language operators — ??, ?., ??=, and ? — that are built around Option.

Updated View as Markdown

Option[T] has exactly two variants: Some(T) when a value is present and None when it is absent. It is an ordinary enum declared in the prelude, so everything you already know about matching applies to it.

fn describe(port: int?): string {
    return match port {
        Some(value) => "listening on ${value}"
        None => "not listening"
    }
}

fn main(): void {
    println(describe(Some(8080)))
    println(describe(None))
}

This page covers what Option means in the language: how absence is spelled, which operators are built around it, and when to convert it into a Result. The complete method catalogue — every signature, with a worked example each — lives in Standard library › Option.

T? and Option[T] are the same type. The suffix form is the preferred spelling in signatures and annotations.

fn main(): void {
    long: Option[string] = Some("Atoll")
    short: string? = Some("Atoll")
    println("${long == short}")
}

There is no null

Atoll has no null literal. A value may be absent only when its type says so, and the checker enforces that at every assignment:

fn main(): void {
    name: string = None
    println(name)
}

That is rejected with ATOLL2002: expected string, found ?…? — None has an optional type and string does not. Widen the annotation instead:

fn main(): void {
    name: string? = None
    println(name ?? "anonymous")
}

The same rule shows up wherever the standard library returns an Option. Indexing a list is one of those places — xs[0] and xs.get(0) both produce int?, never int:

fn first(xs: []int): int {
    return xs[0]
}
fn first(xs: []int): int {
    return xs[0] ?? 0
}

fn main(): void {
    println("${first([7, 8, 9])}")
    println("${first([])}")
}

Construction

Some(value) and None are the constructors. An expected optional type also lifts a bare value, which keeps struct literals and returns readable:

struct Server { host: string, port: int? }

fn main(): void {
    explicit: int? = Some(8080)
    lifted: int? = 8080
    absent: int? = None

    s := Server { host: "localhost", port: 443 }
    total := explicit.unwrap_or(0) + lifted.unwrap_or(0)
        + absent.unwrap_or(0) + s.port.unwrap_or(0)
    println("${total}")
}

None carries no payload, so nothing in the expression itself identifies T. The expected type has to supply it — from an annotation, a parameter, a field, or a return type.

Nested optionals are distinct types. string?? is Option[Option[string]] and can tell “no outer answer” apart from “an outer answer that contains nothing”:

fn lookup(present: bool): string?? {
    if not present { return None }
    return Some(None)
}

fn describe(r: string??): string {
    return match r {
        None => "no answer"
        Some(inner) => match inner {
            None => "answered: no value"
            Some(v) => "answered: ${v}"
        }
    }
}

fn main(): void {
    println(describe(lookup(false)))
    println(describe(lookup(true)))
}

Reach for a nested option only when the two levels genuinely mean different things. Otherwise flatten it or model the states with a named enum.

Matching

Both cases must be covered. Leaving one out is a checker error, not a runtime surprise:

fn f(v: int?): int {
    match v {
        Some(x) => { return x }
    }
}

That reports ATOLL2010: non-exhaustive match on option — missing: None.

Patterns nest, so an option wrapping an enum destructures in one arm:

enum Shape {
    Circle(float)
    Square(float)
}

fn area(s: Shape?): float {
    match s {
        Some(Circle(r)) => { return 3.14159 * r * r }
        Some(Square(w)) => { return w * w }
        None => { return 0.0 }
    }
}

fn main(): void {
    println("${area(Some(Shape.Square(3.0)))}")
    println("${area(None)}")
}

When only the Some branch is interesting, an if pattern is shorter than a full match:

fn name_of(cache: Map[int, string], id: int): string {
    if Some(name) := cache.get(id) {
        return name
    } else {
        return "miss"
    }
}

fn main(): void {
    mut cache: Map[int, string]
    cache.put(1, "ada")
    println(name_of(cache, 1))
    println(name_of(cache, 2))
}

A refutable binding with else keeps the payload in scope for the rest of the function. The else block must diverge — return, error, break, or continue.

fn initial(cache: Map[int, string], id: int): char? {
    Some(name) := cache.get(id) else {
        return None
    }
    return name.char_at(0)
}

fn main(): void {
    mut cache: Map[int, string]
    cache.put(1, "ada")
    println("${initial(cache, 1).is_some()}")
    println("${initial(cache, 2).is_some()}")
}

Fallback: ?? and ??=

?? produces the payload, or evaluates its right-hand side when the left is None. The right side does not run for Some.

fn label(configured: string?): string {
    return configured ?? "untitled"
}

fn main(): void {
    println(label(Some("prod")))
    println(label(None))
}

The right operand of ?? must be the unwrapped type, so ?? does not chain across several options. Combine options with .or(...) first and let ?? close the chain:

fn pick(a: string?, b: string?): string {
    return a ?? b ?? "fallback"
}
fn pick(a: string?, b: string?): string {
    return a.or(b) ?? "fallback"
}

fn main(): void {
    println(pick(Some("a"), Some("b")))
    println(pick(None, Some("b")))
    println(pick(None, None))
}

??= fills a mutable optional place only when it is currently absent. A second ??= on the same place is a no-op:

struct Config { name: string? }

fn main(): void {
    mut c := Config { name: None }
    c.name ??= "default"
    c.name ??= "ignored"
    println(c.name ?? "")
}

Safe access: ?.

?. reads a field or calls a method only when the receiver is Some, and produces None otherwise. The result stays optional.

struct Profile { city: string }
struct Account { profile: Profile? }

fn city(a: Account?): string? {
    return a?.profile?.city
}

fn main(): void {
    resident := Account { profile: Some(Profile { city: "Oslo" }) }
    nomad := Account { profile: None }
    println(city(Some(resident)) ?? "unknown")
    println(city(Some(nomad)) ?? "unknown")
    println(city(None) ?? "unknown")
}

Methods work the same way:

struct User {
    name: string

    fn greet(self): string => "hi ${self.name}"
}

fn greeting(u: User?): string? {
    return u?.greet()
}

fn main(): void {
    println(greeting(Some(User { name: "ada" })) ?? "nobody")
    println(greeting(None) ?? "nobody")
}

?. lifts exactly one access. The member it produces is optional again, so a following plain . sees an Option and fails:

struct User { name: string }

fn shout(u: User?): string? {
    return u?.name.to_upper_ascii()
}

Use map when the whole chain should run against the payload:

struct User { name: string }

fn shout(u: User?): string {
    return u.map(x => x.name.to_upper_ascii()) ?? "NONE"
}

fn main(): void {
    println(shout(Some(User { name: "ada" })))
    println(shout(None))
}

The four optional operators have distinct control behaviour:

Form On Some(value) On None
opt?.field access the field, re-wrap in Some produce None, continue
opt? produce value return None from the enclosing optional function
opt ?? fallback produce value evaluate and produce fallback
place ??= value leave the place unchanged evaluate and store value

Safe access is a transformation, propagation is an exit, and coalescing is recovery. Pick the one that matches the boundary you are standing on.

Propagation

Postfix ? produces the payload and returns None from the enclosing function. That function’s success type must itself be optional.

struct User { name: string, manager: int? }

fn manager_name(users: Map[int, User], id: int): string? {
    user := users.get(id)?
    boss := users.get(user.manager?)?
    return Some(boss.name)
}

fn main(): void {
    mut users: Map[int, User]
    users.put(1, User { name: "ada", manager: Some(2) })
    users.put(2, User { name: "grace", manager: None })
    println(manager_name(users, 1) ?? "none")
    println(manager_name(users, 2) ?? "none")
    println(manager_name(users, 3) ?? "none")
}

? on an option inside a non-optional function is rejected:

fn f(xs: []int): int {
    v := xs.get(0)?
    return v
}

The message is ATOLL2002: ? on an option requires an enclosing function that returns an option. It does not silently invent an error for a T ! E function either — convert absence explicitly with to_result, below.

Propagation covers the full rules for ? on both carriers.

Absence is not failure

The one conversion that belongs on this page is the one that changes the meaning of a value. to_result(err) says “at this boundary, absent means this failure”:

error LookupError { Missing { key: string } }

fn require(settings: Map[string, string], key: string): string ! LookupError {
    missing := LookupError.Missing(key)
    return settings.get(key).to_result(missing)?
}

fn main(): void {
    mut settings: Map[string, string]
    settings.put("host", "localhost")
    match require(settings, "host") {
        Ok(v) => println("host=${v}")
        Err(e) => println("missing host")
    }
    match require(settings, "port") {
        Ok(v) => println("port=${v}")
        Err(e) => println("missing port")
    }
}

The error argument is an ordinary call argument, so it is constructed before the method runs even for Some. When building it is expensive, use a match.

Note the intermediate missing binding: a variant payload written with braces cannot appear directly in an argument list, because the parser reads the { as a block. Bind it first, or use the positional constructor LookupError.Missing("port"). See Error Types.

Going the other way, Result.ok() throws the reason away and leaves you with an Option — deliberate loss of detail, never a default.

The method surface

Every Option method is total; none can panic. They fall into five groups:

Group Methods
Query is_some, is_none, is_some_and
Discharge unwrap_or, unwrap_or_else
Transform map, flat_map, filter
Combine or, or_else, and, zip
Convert to_result, to_list, flatten

Signatures, semantics, and a worked example for each live in Standard library › Option. Two points are worth carrying here because they change program behaviour:

  • the _or_else forms take a zero-argument function, and only call it for None; the plain forms take an already-evaluated argument;
  • flatten is declared on Option[T] returning Option[T], so on a T?? receiver it does not remove a layer at the type level. Use flat_map(v => v) to collapse a nested option.
fn expensive_default(): int {
    return 42
}

fn main(): void {
    v: int? = None
    println("${v.unwrap_or(0)}")
    println("${v.unwrap_or_else(() => expensive_default())}")

    nested: int?? = Some(Some(7))
    flat: int? = nested.flat_map(inner => inner)
    match flat {
        Some(n) => println("collapsed to ${n}")
        None => println("collapsed to nothing")
    }
}

A composed example

Reading configuration is the archetypal Option workload: every lookup can be absent, some absences are fatal and some have defaults.

struct Settings {
    host: string
    port: int
    workers: int
    tls_cert: string?
}

error ConfigError {
    Missing { key: string }
    NotAnInteger { key: string, raw: string }
}

fn required(raw: Map[string, string], key: string): string ! ConfigError {
    missing := ConfigError.Missing(key)
    return raw.get(key).to_result(missing)?
}

fn integer(raw: Map[string, string], key: string, fallback: int): int ! ConfigError {
    text := raw.get(key)
    Some(value) := text else {
        return fallback
    }
    bad := ConfigError.NotAnInteger(key, value)
    return value.to_int().to_result(bad)?
}

fn load(raw: Map[string, string]): Settings ! ConfigError {
    return Settings {
        host: required(raw, "host")?,
        port: integer(raw, "port", 8080)?,
        workers: integer(raw, "workers", 4)?,
        tls_cert: raw.get("tls_cert"),
    }
}

fn main(): void {
    mut raw: Map[string, string]
    raw.put("host", "localhost")
    raw.put("workers", "8")

    match load(raw) {
        Ok(s) => println("${s.host}:${s.port} x${s.workers} tls=${s.tls_cert.is_some()}")
        Err(e) => println("bad config")
    }

    mut broken: Map[string, string]
    broken.put("port", "8080")
    match load(broken) {
        Ok(s) => println(s.host)
        Err(e) => println("bad config")
    }
}

tls_cert stays an Option because absence is legitimate data. host becomes an error because a missing host is a failure the caller must hear about. That choice — absence versus failure — is the whole design decision, and it belongs at the boundary that knows the meaning.

Boundaries

An optional field is a claim that absence is valid data. Document what produces None, whether it can later become Some, and whether omission differs from an empty value:

  • None is not "", 0, false, or [];
  • a nested option is not automatically flattened;
  • converting to Result picks the layer that owns the failure meaning;
  • discarding a Result error with .ok() is a deliberate loss of detail.

The compiler may niche-pack an optional reference or use an explicit tag for an inline scalar. That representation never changes the source cases: every Option[T] is still exactly Some(T) or None, and code must not infer presence from a raw address or integer value.

Do not add ? to every field “for flexibility”. Optionality propagates into construction, matching, serialization, SQL nullability, and every caller’s invariants.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close