Skip to content

Tour

Build one small Atoll program from a hello-world to typed errors, traits, collections, modules, and concurrency.

Updated View as Markdown

This tour builds one program — a small work-item tracker — from a single println to a multi-file project with concurrent loads and streaming results.

Every block is self-contained: paste it into a .at file and run atoll run on it. Except for the three project-file excerpts in Splitting into modules, which only make sense alongside their siblings, each one is verified to pass atoll check and lower to WebAssembly under atoll build. Blocks marked fail are the opposite: they are here because the compiler rejects them, and the book verifies that it still does.

A first program

main is the entry point. Bindings use :=, and mut makes one rebindable.

fn main(): void {
    mut total := 0
    for n in [3, 1, 4, 1, 5] {
        total += n
    }
    println("total: ${total}")
}

total is int because 0 is. [3, 1, 4, 1, 5] is a []int — an owned, growable list. for is the only loop keyword in Atoll; it also takes a plain condition, so for i < 3 { } is the while loop you were looking for.

Data

The tracker needs a closed set of priorities and a record to hold one item.

enum Priority {
    Normal
    Urgent
}

struct WorkItem {
    title: string
    priority: Priority
    done: bool
}

fn main(): void {
    item := WorkItem {
        title: "Publish guide",
        priority: Urgent,
        done: false
    }
    println("${item.title} / ${item.done}")
}

The expected type resolves the bare Urgent; write Priority.Urgent when qualification reads better. Struct declarations separate their fields by newline, but struct literals need commas — this is the single most common first-day syntax error:

struct WorkItem {
    title: string
    done: bool
}

fn main(): void {
    item := WorkItem {
        title: "Publish guide"
        done: false
    }
    println(item.title)
}

Behavior

A trait gives a shared, statically dispatched capability. Implement it for WorkItem and every item gains one line of display text.

enum Priority { Normal  Urgent }

struct WorkItem {
    title: string
    priority: Priority
    done: bool
}

trait Summary {
    fn summary(self): string
}

impl Summary for WorkItem {
    fn summary(self): string {
        marker := match self.priority {
            Normal => "-"
            Urgent => "!"
        }
        return "${marker} ${self.title}"
    }
}

fn main(): void {
    item := WorkItem { title: "Publish guide", priority: Urgent, done: false }
    println(item.summary())
}

The match is exhaustive because it covers every Priority variant. Adding a third priority turns this expression into a compile-time migration site rather than a silent fallthrough. There is no dyn and no runtime method lookup: the call is resolved at the call site.

Failure

Fallible operations return a Result. The T ! E sugar spells one out, and error V leaves through the error channel.

error WorkError {
    EmptyTitle
    Missing { title: string }
}

fn normalize_title(title: string): string ! WorkError {
    clean := title.trim()
    if clean.is_empty() {
        error EmptyTitle
    }
    return clean
}

fn main(): void {
    match normalize_title("  Publish guide  ") {
        Ok(clean) => println("accepted: ${clean}")
        Err(EmptyTitle) => println("rejected: empty")
        Err(Missing { title }) => println("rejected: missing ${title}")
    }
}

normalize_title(" ") takes the EmptyTitle arm. There are no exceptions and no null; the two outcomes are both in the type.

Bind the payload as err or e if you need it — error is a keyword and can never be an identifier.

Propagation

? unwraps Ok and returns the error from the enclosing fallible function. That is what makes a validating constructor short:

enum Priority { Normal  Urgent }

error WorkError { EmptyTitle }

struct WorkItem {
    title: string
    priority: Priority
    done: bool
}

fn normalize_title(title: string): string ! WorkError {
    clean := title.trim()
    if clean.is_empty() { error EmptyTitle }
    return clean
}

fn new_item(title: string, priority: Priority): WorkItem ! WorkError {
    return WorkItem {
        title: normalize_title(title)?,
        priority: priority,
        done: false
    }
}

fn main(): void {
    match new_item("  Publish guide  ", Urgent) {
        Ok(item) => println("created '${item.title}'")
        Err(EmptyTitle) => println("a title was empty")
    }
}

? is only legal where there is an error channel to return through. Using it in main, which returns void, is rejected with ATOLL3051: function main uses error/? but its return type () is not fallible:

error WorkError { EmptyTitle }

fn check(title: string): string ! WorkError {
    if title.is_empty() { error EmptyTitle }
    return title
}

fn main(): void {
    value := check("guide")?
    println(value)
}

Converting errors at a boundary

? propagates an error unchanged, which is right inside one layer and wrong at the edge of one. catch attaches match arms to the error channel so a caller sees your vocabulary, not your dependency’s.

error WorkError {
    EmptyTitle
    Missing { title: string }
}

error ApiError {
    BadRequest { reason: string }
}

fn normalize_title(title: string): string ! WorkError {
    clean := title.trim()
    if clean.is_empty() { error EmptyTitle }
    return clean
}

fn create(title: string): string ! ApiError {
    clean := normalize_title(title) catch {
        EmptyTitle => error BadRequest { reason: "title was empty" }
        Missing { title } => error BadRequest { reason: "no item ${title}" }
    }
    return clean
}

fn main(): void {
    match create("   ") {
        Ok(title) => println("created ${title}")
        Err(BadRequest { reason }) => println("rejected: ${reason}")
    }
}

Each arm here uses error, which leaves create immediately — so clean is a plain string, not another Result. That divergence is the rule to remember: an arm that produces a value produces a replacement error, and the whole catch is still a Result. Only when every arm diverges do you get the success type directly. Catch works through both shapes.

Exhaustiveness applies to catch arms exactly as it does to match: leave out Missing and the program is rejected with ATOLL2010.

Absence

A lookup that can legitimately find nothing returns T? — an Option, not a nullable reference.

struct WorkItem {
    title: string
    done: bool
}

fn find_item(items: []WorkItem, title: string): WorkItem? {
    for item in items {
        if item.title == title {
            return item
        }
    }
    return None
}

fn main(): void {
    mut items: []WorkItem = []
    items.add(WorkItem { title: "Review examples", done: true })
    items.add(WorkItem { title: "Publish guide", done: false })

    if Some(found) := find_item(items, "Publish guide") {
        println("found: ${found.title}")
    }

    missing := find_item(items, "Ship release")
    println(missing?.title ?? "nothing to do")
}

return item is lifted into Some(item) by the optional return type. On the caller side, if Some(x) := opt narrows, ?. reaches through, and ?? supplies the fallback. Indexing works the same way: items[0] is a WorkItem?, so it needs ?? or a pattern before you can use it.

When absence should become a failure at a layer boundary, say so explicitly:

error WorkError { Missing { title: string } }

struct WorkItem { title: string }

fn find_item(items: []WorkItem, title: string): WorkItem? {
    for item in items {
        if item.title == title { return item }
    }
    return None
}

fn require_item(items: []WorkItem, title: string): WorkItem ! WorkError {
    return find_item(items, title).to_result(Missing { title: title })?
}

fn main(): void {
    mut items: []WorkItem = []
    items.add(WorkItem { title: "Publish guide" })

    match require_item(items, "Ship release") {
        Ok(item) => println(item.title)
        Err(Missing { title }) => println("no item named '${title}'")
    }
}

Option never invents a WorkError on its own; to_result states the conversion policy at the boundary that owns it.

Collections and closures

List methods take closures. Both xs.filter(f) and xs.filter { f } parse; the brace form reads better when the body is long.

struct WorkItem {
    title: string
    done: bool
}

fn main(): void {
    mut items: []WorkItem = []
    items.add(WorkItem { title: "Review examples", done: true })
    items.add(WorkItem { title: "Publish guide", done: false })
    items.add(WorkItem { title: "Ship release", done: false })

    open := items.filter { item => !item.done }
    titles := open.map { item => item.title }

    println("${titles.len()} open of ${items.len()}")
    for title in titles {
        println("  ${title}")
    }
}

items must be a mut binding for add: the method takes a mutable receiver and writes the updated list back into the place. Closure parameters are inferred from the expected callback type, so item is a WorkItem without an annotation.

Keep a method call with a brace-closure out of a for header — the parser reads the brace as the loop body. Bind the intermediate list first, as above.

Cleanup on every exit

defer schedules work to run when the enclosing scope leaves — on a normal return, on an early return, and on an error. It is how you keep a release next to the acquisition it belongs to instead of duplicating it down every exit path.

error WorkError { EmptyTitle }

fn normalize_title(title: string): string ! WorkError {
    defer println("  (normalize finished)")
    clean := title.trim()
    if clean.is_empty() { error EmptyTitle }
    return clean
}

fn main(): void {
    match normalize_title("  Publish guide  ") {
        Ok(title) => println("accepted: ${title}")
        Err(EmptyTitle) => println("rejected: empty")
    }
    match normalize_title("   ") {
        Ok(title) => println("accepted: ${title}")
        Err(EmptyTitle) => println("rejected: empty")
    }
}

The deferred println runs twice — once after the successful return, once after the error exit. Deferred statements run in reverse registration order, so cleanup unwinds in the order you built things up. See Defer.

Putting it together

Every piece so far, in one program with a single error boundary:

enum Priority { Normal  Urgent }

error WorkError {
    EmptyTitle
    Missing { title: string }
}

struct WorkItem {
    title: string
    priority: Priority
    done: bool
}

trait Summary {
    fn summary(self): string
}

impl Summary for WorkItem {
    fn summary(self): string {
        marker := match self.priority {
            Normal => "-"
            Urgent => "!"
        }
        return "${marker} ${self.title}"
    }
}

fn normalize_title(title: string): string ! WorkError {
    clean := title.trim()
    if clean.is_empty() { error EmptyTitle }
    return clean
}

fn new_item(title: string, priority: Priority): WorkItem ! WorkError {
    return WorkItem {
        title: normalize_title(title)?,
        priority: priority,
        done: false
    }
}

fn find_item(items: []WorkItem, title: string): WorkItem? {
    for item in items {
        if item.title == title { return item }
    }
    return None
}

fn build_report(headline: string): []string ! WorkError {
    mut items: []WorkItem = []
    items.add(new_item("Review examples", Normal)?)
    items.add(new_item("  Publish guide  ", Urgent)?)

    featured := find_item(items, headline)
        .to_result(Missing { title: headline })?

    mut lines: []string = []
    lines.add("featured: ${featured.summary()}")

    open := items.filter { item => !item.done }
    for item in open {
        lines.add(item.summary())
    }
    return lines
}

fn main(): void {
    match build_report("Publish guide") {
        Ok(lines) => {
            for line in lines { println(line) }
        }
        Err(Missing { title }) => println("no item named '${title}'")
        Err(EmptyTitle) => println("an item had an empty title")
    }
}

Reading it through the language contracts:

  1. items has one static type, []WorkItem, and one mutable place.
  2. Each new_item either produces a complete WorkItem or leaves through WorkError; there is no partially initialized item.
  3. find_item models “not there” without conflating it with “invalid input”.
  4. to_result promotes that absence to the boundary’s own failure type.
  5. main is the one place that has to decide what a failure means to a user.

Splitting into modules

atoll new work scaffolds a project whose manifest names the module root:

work/
  atoll.toml          module = "work"
  src/
    main.at

A file’s module path is the manifest module value plus its directory relative to atoll.toml. The filename is not part of it, so several files in one directory share one module. Growing the tracker into directories:

work/
  atoll.toml          module = "work"
  src/
    main.at           module  work/src
    domain/
      item.at         module  work/src/domain
      errors.at       module  work/src/domain
    report/
      report.at       module  work/src/report

domain/errors.at declares the error type and nothing else:

error WorkError {
    EmptyTitle
    Missing { title: string }
}

domain/item.at sits in the same directory, so it shares the module path and uses WorkError with no import at all. It keeps its own helper private:

enum Priority { Normal  Urgent }

struct WorkItem {
    title: string
    priority: Priority
    done: bool
}

private fn normalize_title(title: string): string ! WorkError {
    clean := title.trim()
    if clean.is_empty() { error EmptyTitle }
    return clean
}

fn new_item(title: string, priority: Priority): WorkItem ! WorkError {
    return WorkItem {
        title: normalize_title(title)?,
        priority: priority,
        done: false
    }
}

report/report.at is in a different directory, so it names what it wants:

import { WorkItem, Priority } from work/src/domain

fn headline(item: WorkItem): string {
    return "* ${item.title}"
}

Import one member or several from a module path; import mod as alias brings in the whole module under a name. Top-level declarations are public by default, and private restricts one to its own file — so normalize_title is unavailable to report.at and to sibling files in domain/. The visibility boundary is the file; the naming boundary is the directory.

Build the whole thing with atoll build -o out . from the project root. A complete, working multi-file project lives in examples/hot-redeploy in the compiler repository if you want to read one that compiles today.

See Modules for import forms, aliases, and the manifest.

Concurrency

Concurrency changes scheduling, not the type or error model. spawn { } starts a child task immediately and hands back a Task[T].

error WorkError { Missing { title: string } }

struct WorkItem {
    title: string
    done: bool
}

fn load_items(owner: string): []WorkItem {
    mut items: []WorkItem = []
    items.add(WorkItem { title: "Publish guide", done: false })
    items.add(WorkItem { title: "Review examples", done: true })
    return items
}

fn load_featured(title: string): WorkItem ! WorkError {
    if title.is_empty() { error Missing { title: title } }
    return WorkItem { title: title, done: false }
}

fn main(): void {
    all_task := spawn { load_items("ada") }
    featured_task := spawn { load_featured("Publish guide") }

    items := all_task.await()

    match featured_task.await() {
        Ok(item) => println("featured: ${item.title}")
        Err(Missing { title }) => println("no item named '${title}'")
    }

    println("${items.len()} items loaded")
}

Both children start before the first await. Task[T] keeps the body’s static result type, so featured_task.await() yields the same WorkItem ! WorkError you would get from a direct call — ?, match, and catch all still apply. Nothing about the error model changed; only the scheduling did.

The compiler also tracks effectsSuspend, Error, Alloc, Spawn, and Cancel. It infers them from what a body calls, and you do not write them down: none of the code above carries an effect annotation. You will see ATOLL2032 warnings once you call something effectful; they are expected and do not stop the build. Effects explains why.

A join that fails still leaves the sibling running. Await it, .cancel() it, or use a combinator whose documented lifecycle owns the losers — see Cancellation.

Streaming results

When the producer should not have to finish before the consumer starts, hand back a Stream[T] instead of a list. for v in stream consumes it until the producer calls close.

struct WorkItem {
    title: string
    done: bool
}

fn stream_open(items: []WorkItem, out: Stream[string]): void {
    for item in items {
        if !item.done {
            out.send(item.title)
        }
    }
    out.close()
}

fn main(): void {
    mut items: []WorkItem = []
    items.add(WorkItem { title: "Publish guide", done: false })
    items.add(WorkItem { title: "Review examples", done: true })
    items.add(WorkItem { title: "Ship release", done: false })

    out := Stream.new(4)
    producer := spawn { stream_open(items, out) }

    mut seen := 0
    for title in out {
        seen += 1
        println("  ${seen}. ${title}")
    }
    producer.await()
    println("${seen} open items")
}

Stream.new(4) gives a channel with a four-item buffer, so the producer runs ahead only that far before send suspends. Awaiting the producer after the loop is not optional bookkeeping: it is where a failure inside stream_open would surface.

Where to go next

  1. Fundamentals — source text, bindings, functions, calls, closures, operators.
  2. Control flow and Types — patterns, match, the four for forms, defer, generics, traits, value semantics.
  3. ErrorsOption, Result, ?, catch, error unions.
  4. Modules and Concurrency.
  5. Standard Library for concrete APIs, and the Language Specification for normative grammar.

Two pages are worth a look before you write much code. Diagnostics shows the compiler errors you will meet first, with real output next to the source that produced it. Feature Status records which prelude methods actually lower — several string and List methods type-check and then fail at atoll build, and knowing which ones saves an afternoon.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close