Skip to content

Declarations

Every module-level declaration form — fn, const, struct, enum, error, type, trait, impl, schema, model — plus modifiers, decorators, generics, and forward references.

Updated View as Markdown

A declaration introduces a named program item at the top level of a file. Everything an Atoll file contains is a declaration — there is no top-level statement, no top-level expression, and no module initializer.

const DEFAULT_PORT: u16 = 8080

struct Server {
    host: string
    port: u16
}

fn start(server: Server): string {
    return "${server.host}:${server.port}"
}

fn main(): void {
    println(start(Server { host: "localhost", port: DEFAULT_PORT }))
}

Code that is not inside a declaration is rejected before name resolution even begins:

x := 1

fn main(): void {
    println("${x}")
}

The declaration forms

Every core top-level form appears in this one unit. Each is described in its own section below.

const RETRY_BUDGET: int = 3

struct Server { host: string, port: u16 }

enum State { Idle, Running, Done }

error StartError { PortInUse, Denied }

type Port = u16

distinct type ServerId = int

trait Describe {
    fn describe(self): string
}

impl Describe for Server {
    fn describe(self): string => "${self.host}:${self.port}"
}

fn start(server: Server, state: State): string ! StartError {
    if server.port == 0 {
        error PortInUse
    }
    match state {
        Running => return server.describe()
        Idle => error Denied
        Done => error Denied
    }
}

fn main(): void {
    port: Port = 8080
    server := Server { host: "localhost", port: port }
    println("budget ${RETRY_BUDGET}")
    println(start(server, Running).unwrap_or("not running"))
    println(start(server, Idle).unwrap_or("not running"))
}
Form Introduces
fn A function, method, or receiver-prefixed method
const A compile-time constant value
struct A nominal product type
enum Named closed alternatives
error Error variants, or a union of error types
type A transparent alias
distinct type A new nominal type over an existing representation
trait A statically dispatched behaviour contract
impl Inherent or trait behaviour for a type
schema The logical SQL schema a unit’s models belong to
model A SQL-backed nominal row type

module and import are file items rather than declarations: they name the file’s module and pull names in, but they do not introduce a value or a type.

Words such as service, actor, job, component, and guard appear in older design documents. They are not accepted declaration keywords.

Constants

const binds a name to a value that the compiler folds before code generation. The type annotation is optional when the initializer determines it.

const MAX_RETRIES: int = 3
const TIMEOUT_MS = 250
const GREETING: string = "hello"
const SAMPLE_RATE: float = 0.25
const TRACING: bool = false

const MAX_ATTEMPTS: int = MAX_RETRIES * 2

fn main(): void {
    println("${GREETING} retries=${MAX_ATTEMPTS} rate=${SAMPLE_RATE} ms=${TIMEOUT_MS} trace=${TRACING}")
}

Constants may refer to earlier constants, because the whole expression is evaluated at compile time. Calling a function is not const-evaluable, and the compiler says so:

fn now(): int => 7

const STARTED_AT: int = now()

Constant names use SCREAMING_SNAKE_CASE. Use one when the value is genuinely fixed for the program; use a static function when the value should be constructed at run time.

Types

struct declares a product type, enum a closed set of alternatives, and error a set of error variants. All three are nominal: two declarations with identical contents remain different types.

struct Reading {
    sensor: string
    celsius: float
}

enum Severity { Info, Warn, Critical }

enum Sample {
    Missing
    Point(float)
    Span(float, float)
}

error ReadError { Offline, OutOfRange }

fn classify(s: Sample): Severity {
    match s {
        Missing => return Critical
        Point(v) => if v > 100.0 { return Warn } else { return Info }
        Span(lo, hi) => if hi - lo > 50.0 { return Warn } else { return Info }
    }
}

fn main(): void {
    r := Reading { sensor: "boiler", celsius: 91.5 }
    match classify(Point(r.celsius)) {
        Info => println("${r.sensor} ok")
        Warn => println("${r.sensor} warn")
        Critical => println("${r.sensor} critical")
    }
}

Enum variants carry positional payloads written in parentheses. Full detail lives in Structs and Enums.

An error declaration can also flatten several error types into one:

error StartError { PortInUse, Denied }
error IoError { NotFound, Truncated }

error AppError = StartError | IoError

fn main(): void {
    println("declared")
}

Aliases

type introduces a transparent alias — the alias and its target are the same type, and values pass freely between them.

type Port = u16
type Row = { id: int, label: string }

fn bind(p: Port): string => "bound ${p}"

fn describe(r: Row): string => "${r.id}:${r.label}"

fn main(): void {
    println(bind(8080))
    println(describe({ id: 1, label: "first" }))
}

distinct type introduces a new nominal type that shares a representation with its target but does not interchange with it. That is the point: it turns a stringly- or numerically-typed API into a checked one.

distinct type ServerId = int
distinct type TenantId = int

struct Assignment {
    server: ServerId
    tenant: TenantId
}

fn assign(server: ServerId, tenant: TenantId): Assignment {
    return Assignment { server, tenant }
}

fn main(): void {
    println("declared")
}

Passing a bare int where a distinct type is expected is an error, which is exactly what makes the two ids impossible to swap:

distinct type ServerId = int

fn label(id: ServerId): string => "server"

fn main(): void {
    println(label(1))
}

That nominality currently has no escape hatch: there is no conversion form that turns a representation value into a distinct type value, so a distinct type over a primitive can be declared and threaded through signatures but not constructed. Reach for a one-field struct when you need both the nominal guarantee and a way to build a value today.

struct ServerId { value: int }
struct TenantId { value: int }

struct Assignment {
    server: ServerId
    tenant: TenantId
}

fn assign(server: ServerId, tenant: TenantId): Assignment =>
    Assignment { server, tenant }

fn main(): void {
    a := assign(ServerId { value: 7 }, TenantId { value: 3 })
    println("${a.server.value} -> ${a.tenant.value}")
}

Behaviour

trait declares requirements, optionally with default bodies, associated types, and associated constants. impl supplies them for a concrete type.

trait Shape {
    fn area(self): float
    fn name(self): string => "shape"
}

struct Square { side: float }
struct Circle { radius: float }

impl Shape for Square {
    fn area(self): float => self.side * self.side
    fn name(self): string => "square"
}

impl Shape for Circle {
    fn area(self): float => 3.14159 * self.radius * self.radius
}

fn report[T](s: T): string where T: Shape => "${s.name()} ${s.area()}"

fn main(): void {
    println(report(Square { side: 2.0 }))
    println(report(Circle { radius: 1.0 }))
}

Circle inherits the default name. Dispatch is static: report is checked against the Shape bound and specialized per concrete type — there is no dyn Shape.

An impl block without a trait supplies inherent behaviour:

struct Vec2 { x: float, y: float }

impl Vec2 {
    fn zero(): Vec2 => Vec2 { x: 0.0, y: 0.0 }
    fn scaled(self, k: float): Vec2 => Vec2 { x: self.x * k, y: self.y * k }
    fn length(self): float => (self.x * self.x + self.y * self.y).sqrt()
}

fn main(): void {
    v := Vec2 { x: 3.0, y: 4.0 }
    println("${v.length()} ${v.scaled(2.0).x} ${Vec2.zero().x}")
}

See Traits and Implementations.

Data

A unit that runs SQL needs a schema declaration naming the logical schema, plus a model declaration for each row type. Models are ordinary nominal types that additionally describe a table.

schema shop

model Item {
    @id
    id: int
    name: string
    price: float
}

fn affordable(limit: float): []{ id: int, name: string } ! SqlError {
    return FROM Item WHERE price < limit SELECT { id, name }?
}

fn main(): void {
    rows := affordable(9.99) catch {
        err => {
            println("query failed")
            return
        }
    }
    println("${rows.len()} affordable items")
}

Both declarations must be present in the same compilation unit as the query. See Models.

Modifiers

A declaration may carry visibility, safety, decorators, generic parameters, and a where clause:

@inline
private fn largest[T](a: T, b: T): T
where T: Comparable[T]
{
    if a > b { return a }
    return b
}

pub const VERSION: string = "1.4.0"

pub struct Release { tag: string }

private struct Draft { note: string }

fn main(): void {
    println("${VERSION} ${largest(2, 9)} ${Release { tag: VERSION }.tag}")
}
Modifier Effect
pub Reachable outside the declaring file; this is the default
private Restricted to the declaring file
unsafe Moves proof obligations onto the function and its callers
@decorator Checked metadata for the compiler, runtime, or SQL layer

unsafe

unsafe fn marks a declaration whose contract the compiler cannot verify — raw addresses, hand-managed lifetimes, representation punning. The obligation propagates: a caller must either be unsafe fn itself or open an unsafe { ... } block, which is an expression like any other.

unsafe fn slot_offset(index: int): int => index * 8

unsafe fn two_slots(): int => slot_offset(1) + slot_offset(2)

fn main(): void {
    near := unsafe { slot_offset(3) }
    far := unsafe { two_slots() }
    println("${near} ${far}")
}

Calling one from ordinary code without that block is rejected, which is the whole point of the marker:

unsafe fn slot_offset(index: int): int => index * 8

fn main(): void {
    println("${slot_offset(3)}")
}

Keep the block as small as the obligation. Wrapping a whole function body in unsafe { } hides which line actually needs the audit.

Decorators

Each decorator is interpreted by the subsystem that owns it. @derive generates trait implementations on demand; @id marks a model’s primary key; @inline and @weak are compiler hints.

@derive(Equatable, Hashable)
struct Tag {
    name: string
}

fn unique(tags: []Tag): Set[Tag] {
    mut seen := Set.new[Tag]()
    for t in tags {
        seen.add(t)
    }
    return seen
}

fn main(): void {
    tags := [Tag { name: "a" }, Tag { name: "a" }, Tag { name: "b" }]
    println("${unique(tags).size()}")
}

@derive is demand-driven: it generates only the implementations something actually asks for, so adding it to a type that is never compared or hashed costs nothing. See Decorators.

Generic parameters

Type parameters are written in square brackets after the name. Bounds go inline or in a where clause; both forms mean the same thing.

struct Page[T] {
    items: []T
    cursor: string?
}

fn first_of[T](p: Page[T]): T? => p.items.get(0)

fn largest[T: Comparable[T]](xs: []T): T? => xs.max()

fn pick[T](a: T, b: T): T where T: Comparable[T] {
    if a > b { return a }
    return b
}

fn main(): void {
    p := Page { items: [3, 1, 4], cursor: None }
    println("${first_of(p) ?? 0} ${largest(p.items) ?? 0} ${pick(2, 9)}")
}

Each use site selects its own type arguments. See Generics.

Bodies

Ordinary functions need a block body or an => expression body. Type declarations contain only member forms valid for that type.

fn double(value: int): int => value * 2

fn describe(value: int): string {
    if value < 0 {
        return "negative"
    }
    return "non-negative"
}

fn main(): void {
    println("${double(21)} ${describe(-1)}")
}

A signature without a body is a requirement, not an abstract runtime method. It is legal in a trait, where an impl must supply it:

trait Store {
    fn get(self, key: string): string?
    fn put(mut self, key: string, value: string): void
}

struct Memo { entries: Map[string, string] }

impl Store for Memo {
    fn get(self, key: string): string? => self.entries.get(key)

    fn put(mut self, key: string, value: string): void {
        self.entries.put(key, value)
    }
}

fn main(): void {
    mut m := Memo { entries: Map.new[string, string]() }
    m.put("a", "1")
    println(m.get("a") ?? "missing")
}

Functions do not nest. A helper is a sibling declaration, not a declaration inside another body:

fn main(): void {
    fn helper(): int => 1
    println("${helper()}")
}

Collection order

Top-level declarations are collected across the whole project before any body is resolved, so source order does not constrain reference order. A function may call one declared later, and a type may be used above its declaration.

fn main(): void {
    println("${answer()} ${Config.default().retries}")
}

fn answer(): int => LUCKY_NUMBER

const LUCKY_NUMBER: int = 42

struct Config {
    retries: int

    fn default(): Config => Config { retries: MAX_RETRIES }
}

const MAX_RETRIES: int = 3

This is declaration visibility, not hoisting of runtime effects. Local bindings inside a body still become visible only after their own statement:

fn main(): void {
    println("${total}")
    total := 10
}

A useful file order — imports, public constants and types, public functions, private helpers — is a readability convention, not a resolution rule.

Namespaces

Declarations resolve in the namespace appropriate to the position they appear in. A type name and a local binding can share spelling because they are never looked up in the same place, while Type.member and a qualified enum variant say explicitly which namespace to search.

enum Status { Open, Closed }

struct Ticket {
    id: int
    status: Status

    fn is_open(self): bool => self.status == Status.Open
}

fn main(): void {
    ticket := Ticket { id: 7, status: Open }
    println("${ticket.id} ${ticket.is_open()}")
}

Status.Open and the bare Open select the same variant; the qualified form is worth writing when several enums in scope share a variant name.

Files and modules

Imports appear above the declarations and pull names in either individually or as a namespace alias.

import { mean, peak } from example.dev/telemetry/stats
import example.dev/telemetry/format as fmt

const HEADER: string = "daily report"

fn main(): void {
    println(HEADER)
}

A directory is a module: sibling files share one namespace and do not import each other, so a file only ever imports from other directories. A file may additionally open with a module example.dev/telemetry header naming the module it belongs to; that name has to agree with the file’s location on disk, so it is only meaningful inside a real project. See Modules.

A complete module

The forms compose into an ordinary file: constants at the top, the types the API exposes, the trait that abstracts over them, and the functions callers reach for.

const WARN_CELSIUS: float = 80.0
const FAIL_CELSIUS: float = 100.0

error SensorError { Offline, Implausible }

enum Grade { Ok, Warn, Fault }

struct Sensor {
    name: string
    celsius: float
    online: bool
}

trait Graded {
    fn grade(self): Grade
    fn label(self): string => "unlabelled"
}

impl Graded for Sensor {
    fn grade(self): Grade {
        if self.celsius >= FAIL_CELSIUS { return Fault }
        if self.celsius >= WARN_CELSIUS { return Warn }
        return Ok
    }

    fn label(self): string => self.name
}

fn read(s: Sensor): Grade ! SensorError {
    if !s.online {
        error Offline
    }
    if s.celsius < -273.15 {
        error Implausible
    }
    return s.grade()
}

fn summarize[T](item: T): string where T: Graded {
    match item.grade() {
        Ok => return "${item.label()}: ok"
        Warn => return "${item.label()}: warn"
        Fault => return "${item.label()}: fault"
    }
}

fn main(): void {
    sensors := [
        Sensor { name: "boiler", celsius: 91.5, online: true },
        Sensor { name: "intake", celsius: 20.0, online: true },
        Sensor { name: "flue", celsius: 140.0, online: false },
    ]

    for s in sensors {
        grade := read(s) catch {
            err => {
                println("${s.name}: unreadable")
                continue
            }
        }
        println(summarize(s))
    }
}

Evolution

A public declaration is a contract. These edits change source compatibility even when the body still computes the same answer:

Change Breaks
Rename or move a declaration Imports and qualified references
Add a parameter without a default Every call
Change a success or error type Callers, ? propagation, and matches
Add an enum or error variant Exhaustive matches
Add a trait member without a default Every implementation
Change a field’s type or visibility Construction, access, and layout
Add an effect to a signature Callers with a narrower declared row

Changing a parameter default is also API surface: existing calls keep compiling and silently change behaviour. Keep helpers private so only the declarations you intend to support are reachable from other files.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close