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:
itemshas one static type,[]WorkItem, and one mutable place.- Each
new_itemeither produces a completeWorkItemor leaves throughWorkError; there is no partially initialized item. find_itemmodels “not there” without conflating it with “invalid input”.to_resultpromotes that absence to the boundary’s own failure type.mainis 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.atA 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/reportdomain/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 effects — Suspend, 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
- Fundamentals — source text, bindings, functions, calls, closures, operators.
- Control flow and Types —
patterns,
match, the fourforforms,defer, generics, traits, value semantics. - Errors —
Option,Result,?,catch, error unions. - Modules and Concurrency.
- 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.