Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

rask logo rask logo

What Rask is

A systems language built around one bet: if references can’t be stored, lifetime annotations stop being necessary.

Borrow a value for a call or an expression and it works the way you’d expect. Put the borrow in a struct field, or return it, and there’s no syntax for what you’re asking — which is the point. Nothing outlives the thing it points at, so there’s nothing to track, and signatures carry types and nothing else.

Graphs need stored references, so exactly one kind exists: a Link into a Rack. The rack owns every node and outlives every link into it, so even that one has no lifetime to name.

Somewhere between Rust and Go. Closer to Rust on safety, closer to Go on ceremony.

import fs
import io

func grep(path: string, pat: string) -> void or io.IoError {
    mut file = try fs.open(path)
    ensure file.close()

    let text = try file.read_text()
    for line in text.lines() {
        if line.contains(pat) { println(line) }
    }
}

Three rules meet in those eight lines. fs.open hands back a linear resource the compiler makes you consume exactly once. ensure defers that consumption to the end of the scope. try returns early on failure — and because the consumption is already scheduled, leaving early still closes the file. No defer discipline to remember, no destructor running out of sight.

Where to start

Rask is early and a solo project. Expect gaps in these chapters and bugs in the compiler; the issue tracker is the honest picture.

New here. Install it, write your first program, then read the chapters under Learn the language. They take one concept at a time and say why each rule is the way it is, which is the part the specs leave out. There are also exercises in the repo. Nothing to install to look around: the playground runs Rask in the browser.

Already writing Rask. The language card is the whole language on one page for looking a rule up. The example programs are complete and CI-checked. The specs are the normative wording when the other two disagree.

Here for the design. The blog is the long-form argument; CORE_DESIGN.md is the principles it falls out of, and RULINGS.md is how open questions get settled.

Try it in the browser

The playground runs Rask without installing anything. It’s the interpreter compiled to WebAssembly, so the code runs in your browser rather than on a server.

Open the playground →

Write code on the left, Ctrl+Enter to run, output on the right. Run tests runs the file’s test blocks instead of main. The examples dropdown loads the programs from examples/, and Copy link gives you a URL with your code in it.

What it can’t do

A browser has no files, no sockets, no clock and no threads, so anything needing one of those is refused with a message rather than half-working:

fs, io, net, httpno filesystem or sockets
timeno clock — which is also why benchmark blocks don’t run here
spawn { }, Thread.spawn { }, ThreadPool.spawn { }, using Multitasking, using ThreadPoolno threads
extern "C", import cno libc to call

Everything else runs: collections, structs, enums, generics, interfaces, pattern matching, closures, error handling, comptime. Recursion is capped a few hundred frames deep, because the browser puts a much lower ceiling on call depth than an OS thread does.

The dropdown only lists examples that actually run here. The rest are in examples/; to run those, install Rask and run the file directly.

Source

Playground UI · WASM bindings

Installation

Note: Rask is in early development. Expect breaking changes.

Prerequisites

  • Rust toolchain (for building from source)
  • Git
  • A C compiler (cc) — the runtime is compiled from C

Building from Source

git clone https://github.com/rask-lang/rask.git
cd rask/compiler
cargo build --release

The rask binary will be in compiler/target/release/rask.

Verify Installation

./target/release/rask --version

CLI Commands

CommandWhat it does
rask run <file>Compile and execute a .rk program
rask check <file>Type-check without running
rask fmt <file>Format source code
rask lint <file>Check style and idioms

Compiled binaries are written to build/debug/.

Running Examples

The repository includes working examples:

./target/release/rask run ../examples/hello_world.rk

Next Steps

Your First Program

Create a file called hello.rk:

func main() {
    println("Hello, Rask!")
}

Run it:

rask run hello.rk

Output:

Hello, Rask!

What’s Happening?

  • func main() is the program entry point
  • println() is a builtin for printing with newline

Variables

Let’s try variables:

func main() {
    let name = "Rask"
    mut year = 2026
    year += 1
    println(format("Hello from {} in {}!", name, year))
}
  • let binds a name once: no reassignment, and no mutating the value either (you can still move it — handing ownership away isn’t mutation)
  • mut is what you reach for when the value needs to change — reassignment or a mutating method like v.push(x)
  • Types are inferred, but you can write them explicitly: let year: i64 = 2026

const exists too, but it’s only for module-level constants — not for locals inside a function.

Functions

func greet(name: string) {
    println(format("Hello, {}!", name))
}

func main() {
    greet("World")
}

Functions that return values need explicit return:

func add(a: i32, b: i32) -> i32 {
    return a + b
}

func main() {
    let result = add(2, 3)
    println(format("2 + 3 = {}", result))
}

Next: Explore the Guide

Next: Errors are values, on what a function returns when it can’t do what you asked.

When there might be nothing

Some things might not be there. string? is the type that says so: a string, or nothing at all.

struct Profile {
    name: string
    email: string?
}

name is always there. email might not be, and the ? is the whole difference. Nothing else in the program can forget it — there is no path where an absent email arrives looking like a string.

Getting at the value

match takes both cases:

func greet(p: Profile?) -> string {
    match p {
        Profile as found => return "hello {found.name}"
        none => return "nobody here"
    }
}

The shorter form tests with x? and names the value with as:

func shout(p: Profile?) -> string {
    if p? as found {
        return "HELLO {found.name}"
    }
    return "nobody here"
}

as found names what the test just proved exists, and found is a plain Profile inside the block.

Without as, x? is just a bool:

func anybody(p: Profile?) -> string {
    if p? {
        return "yes"
    }
    return "no"
}

?? supplies the other branch

Most of the time you don’t want to branch, you want a value either way. ?? gives the right side when the left is absent:

func email_of(p: Profile) -> string {
    return p.email ?? "no email"
}

The right side can also leave instead of producing a value — ?? return, ?? break, ?? continue. There’s no binder, because there’s nothing to bind: absence carries no payload.

Chaining with ?.

p?.email reads email when p is there and gives none when it isn’t — no branch, no binder:

func email_or(p: Profile?, fallback: string) -> string {
    return p?.email ?? fallback
}

Two things can be missing here: the profile, and the email on it. p?.email is a string? all the same, not two layers of absence — “no profile” and “a profile with no email” are the same answer to whoever asked for an email. So one ?? finishes the job.

A longer chain works the same way: every link short-circuits, and the end of it is still one layer.

Running it

The code above is one program, and it prints:

hello ada
nobody here
HELLO ada
nobody here
yes
no
[email protected]
no email
[email protected]
unknown
unknown

Rules behind this page

  • Optionals: T?, none, and the ? family
  • Error types: the other shape, where the second branch carries a reason

Errors are values

When a call can fail, the failure comes back out of it.

A function that can fail says both outcomes in its return type. parse returns i64 or ParseError: a number, or a reason it isn’t one. match takes both:

func years_from(text: string) -> string {
    let n: i64 or ParseError = text.parse<i64>()
    match n {
        i64 as years => return "{years} years"
        ParseError as e => return "not a number: {e.message()}"
    }
}

One arm per outcome, each naming what it got.

Every error type has a message(), so e.message() works whatever kind of error arrived.

The annotation on n is there to show the type. You’d normally leave it off.

catch writes the error arm

Most of the time the error arm is just “use this value instead”. catch is that arm on its own, and the success value comes straight out:

func retries_from(text: string) -> i64 {
    return text.parse<i64>() catch _ => 3
}

The binder is required. catch e => uses the error, catch _ => throws it away. There is no catch 3:

error[E0100]: `catch` needs a binder
  --> docs/book/errors/errors-are-values/bare_catch.rk:11:31
    |
 11 |     let n = text.parse<i64>() catch 3
    |                               ^^^^^ here
    = fix: write `catch e => value` to use the error, or `catch _ => value` to
          drop it — there is no bare-value form, so an error is never swallowed
          silently
    = why: a fallback is the one form here that can destroy information. `??`
          discards a `none`, which carried nothing; `catch` discards or
          transforms an error, which carried a payload someone should look at.
          So the binder is what makes the second one legible — `catch _ =>` is
          the spelling for "an error dies here", and it greps [type.errors/ER14]

A catch body can also leave instead of producing a value — catch e => return wrap(e) turns the error into your own and hands it to the caller.

try writes the commonest one

The error arm people write most is “hand it to my caller unchanged”. try r does what r catch e => return e does:

func doubled(text: string) -> i64 or ParseError {
    let n = try text.parse<i64>()
    return n * 2
}

doubled can’t swallow the failure, so its own return type carries it: i64 or ParseError.

The error has to go somewhere, so the function needs an error branch to put it in. This one doesn’t have one:

func report(text: string) {
    let port = try port_of(text)
    println("{port}")
}
error[E0316]: `try` requires function returning Result or Option, found `void`
  --> docs/book/errors/errors-are-values/try_without_a_carrier.rk:30:16
    |
 30 |     let port = try port_of(text)
    |                ^^^^^^^^^^^^^^^^^ try used here
    = fix: change the function return type to `T or E`
    = why: `try` propagates errors upward — the enclosing function must declare
          an error type in its return

! stops the program

The last thing the error arm can do is give up. ! takes the value out, and panics if the error came back instead:

func main() {
    let port = "eighty-eighty".parse<i64>()!
    println("listening on {port}")
}
panic at docs/book/panics/errors-are-values/force_on_an_error.rk:10: ! on a value that was an error: non-numeric character

A panic is not an error value. It has no type, it never appears in a return type, and a caller can’t handle it. The task dies, its ensure cleanups run on the way out, and any locks it held are released.

Use ! where there is no sensible way to carry on — a config the program can’t start without. For anything the caller could deal with, hand it back with try.

Your own error type

ParseError came with parse. For your program’s own failures, write an enum and give it a message():

enum ConfigError {
    Missing(string)
    Malformed(string)
}

ConfigError implements Error {
    func message(self) -> string {
        match self {
            ConfigError.Missing(key) => return "{key} is not set"
            ConfigError.Malformed(key) => return "{key} is not a number"
        }
    }
}

Then it goes on the right of or:

func port_from(text: string) -> i64 or ConfigError {
    if text == "" {
        return ConfigError.Missing("port")
    }
    let n = text.parse<i64>() catch _ => {
        return ConfigError.Malformed("port")
    }
    return n
}

Running it

The code above is one program, and it prints:

41 years
not a number: non-numeric character
retries 5
retries 3
doubled 82
port 8080
bad config: port is not set
bad config: port is not a number

Rules behind this page

  • Error types: T or E, try, catch, and the Error interface
  • Panics: the other failure channel, for bugs rather than expected outcomes

Passing Values

Every parameter answers one question: does the caller still have this afterwards?

For a lot of values the answer is yes and there’s nothing to think about. For the rest, the code says which answer applies, at both ends. This page goes from the first kind to the second.

Small values are copied

Start with the easy half. Pass an i64 and the callee gets its own copy:

func charge_twice(amount: i64) -> i64 {
    return amount * 2
}
    // Small values (16 bytes or less, all-Copy fields) are copied, so the
    // callee gets its own and nothing is marked at either end.
    let fee = 5
    let doubled = charge_twice(fee)
    println("doubled to {doubled}, and fee is still {fee}")

No marker at either end, because nothing happened to fee that the caller needs to know about.

The line is 16 bytes or less, with every field itself copyable. That covers the integers, the floats, bool, char, and small structs built out of those. It’s fixed and not configurable: moving it would change what existing programs mean, so it’s a semantic boundary rather than a tuning knob.

So for that whole family, ignore the rest of this page. Pass them around and stop worrying.

Everything else has one owner

Now the other half: a string, a Vec, a struct bigger than the line. Copying one of those means copying whatever it owns on the heap, and Rask won’t do that behind your back — a copy that costs an allocation is a copy you should be able to see.

So a value like that has exactly one owner at a time. One name is responsible for it, and when that name goes out of scope the value is released.

Which leaves two ways to hand it to a function:

  • Lend it. The callee uses it for the length of the call. You still own it afterwards.
  • Give it away. The callee owns it now. You don’t have it any more.

The next three sections are those two options — lending to read, lending to write, and giving.

Lend it to read: the default

// No marker: the callee reads, the caller keeps the value.
func describe(account: Account) -> string {
    return "{account.owner}: {account.balance}"
}

No marker at either end, and the reason is that nothing happened. account is the same after the call as it was before: same owner, same contents. There’s no cost to flag and no surprise to warn about, so the syntax stays out of the way. This is what most parameters do.

The lend lasts exactly as long as the call. That’s what makes it safe without any annotation — describe can’t stash the account somewhere and still be holding it after it returns, because a reference can’t be stored.

A borrow isn’t yours to give away, either. Try to pass it on to something that takes ownership and the compiler stops you:

error[E0835]: cannot give away `account` — it's borrowed, not owned
  --> docs/book/errors/passing-values/consume_borrowed.rk:15:22
    |
 14 | func describe(account: Account) -> i64 {
    |               ------- `account` is declared as a borrowed parameter
 15 |     return close_out(account)
    |                      ^^^^^^^ `close_out` takes ownership, and `account` isn't yours to give
    = fix: take it: `take account: …` in the signature — then the caller can see
          it goes
    = why: the caller keeps a parameter it didn't mark `take` and goes on using
          it, so consuming it here would leave them holding something that's
          gone. For a `@resource` that's a second close of a real handle.
          [mem.parameters/PM1, mem.linear/L1]

The caller never gave this up, so they’re still using it. Consuming it here would leave them holding something that’s gone. For a file handle or a transaction, that’s a second close of a real resource.

Lend it to write: mutate

Same deal — the caller still owns it afterwards — except the callee writes through:

// `mutate`: the callee writes through, the caller still owns it afterwards.
func deposit(mutate account: Account, amount: i64) {
    account.balance += amount
}

The call site marks it too, and that’s not optional:

    deposit(mutate account, 50)        // mutate: marker required

Leave the marker off and you get the one-token fix, plus the reason:

error[E0373]: `deposit` mutates `account` — mark it at the call site
  --> docs/book/errors/passing-values/missing_marker.rk:16:13
    |
 16 |     deposit(account, 50)
    |             ^^^^^^^ passed to the `mutate account` parameter
    = fix: deposit(mutate account, 50)
    = why: the compiler backstops a misread *move* — using a value after it's
          moved is an error — but nothing backstops a misread mutation: both
          readings are legal code, so the one that can't be caught gets written
          down. The marker follows the signature, not the argument's size, so a
          Copy argument writes it too. A method receiver is exempt —
          `player.take_damage(10)` operates on the receiver by construction
          [mem.parameters/PM4, PM5]

Here’s the thinking behind that. If you misread a move, the compiler catches it for you: use the value again and you get an error naming where it went. If you misread a mutation, nothing catches it. Both readings are legal code, and the value looks the same afterwards, just different. So the case nobody can catch for you is the one you write down.

Because the rule is syntactic it has no exceptions to memorise: the marker is required exactly when the parameter says mutate, whatever the argument’s type or size. An i64 writes it too — the copy rule decides what the callee gets, not whether you have to say so.

One thing catches everyone once. let is deep:

error[E0302]: cannot mutate `account` — declared `let`
  --> docs/book/errors/passing-values/let_as_mutate.rk:16:20
    |
 16 |     deposit(mutate account, 50)
    |                    ^^^^^^^ `account` is a let binding — immutable
    = fix: replace `let account` with `mut account`
    = why: `let` bindings forbid rebinding and mutation. Use `mut` when you need
          to modify the value or call mutating methods.

let doesn’t mean “this name won’t be reassigned.” It means nothing changes through this name, including through a mutating method, an index, or a field assignment.

Give it away: take

// `take`: the callee keeps it. The name dies at the call site.
func close_out(take account: Account) -> i64 {
    return account.balance
}

Now the caller doesn’t have it. Reach for the name again and it’s gone:

error[E0800]: use of moved value: `account`
  --> docs/book/errors/passing-values/use_after_take.rk:17:19
    |
 16 |     let n = close_out(account)
    |                       ------- value moved here
 17 |     println("{n} {account.balance}")
    |                   ^^^^^^^ value used here after move
    = note: `Account` is 24 bytes (copy threshold is 16) — assignment moves
           instead of copying
    = help: add `account.clone()` if you need an independent copy

That note is the whole reason take needs no marker at the call site: the compiler will tell you exactly where the value went, the moment you reach for it again. own account is available when you want the call site to shout anyway.

You can’t take a small value

Back to the easy half for a second, because the two rules meet here. Write take on an i64 and the compiler stops you:

error[E0404]: `take` on `i64` takes nothing — a Copy value is copied, not given
  --> docs/book/errors/passing-values/take_on_copy.rk:6:24
    |
  6 | func charge_twice(take amount: i64) -> i64 {
    |                        ^^^^^^ `amount` is a `i64`, and the caller keeps theirs
    = fix: drop the keyword: `amount: i64`
    = why: a mode marker is read at the call site to know what happens to the
          argument, so one that says the wrong thing is worse than none
          [mem.parameters/PM6c]

take is a promise to the caller that they lose the value, and an i64 is copied — they don’t. A marker that says the wrong thing is worse than no marker, so the signature has to drop it.

That is about what you write. Handing a Copy value to a take parameter is still fine, and common — it just happens through a generic, where the declaration couldn’t know:

    // A Copy value handed to a generic `take` is copied too. `Vec.push` is
    // declared `push(mutate self, take item: T)`, and `fee` survives both.
    mut charges: Vec<i64> = []
    charges.push(fee)
    charges.push(fee * 2)
    println("charged {charges[0]} and {charges[1]}, and fee is still {fee}")

Vec.push is push(mutate self, take item: T). At T = i64 the push copies, and fee is still there for the next line.

Receivers are never marked

extend Account {
    // A receiver is never marked at the call site, mutating or not.
    func charge_fee(mutate self, fee: i64) {
        self.balance -= fee
    }
}
    account.charge_fee(5)              // receiver: never marked

charge_fee takes mutate self and the call site still says nothing. The receiver is the thing being operated on, which is what the dot means. Marking it would put noise on every mutating method in the language.

Putting it together

    mut account = Account(owner: "Ada", balance: 100)

    println(describe(account))         // borrow: no marker
    deposit(mutate account, 50)        // mutate: marker required
    account.charge_fee(5)              // receiver: never marked
    println(describe(account))

    let final = close_out(account)     // take: account is gone after this
    println("closed with {final}")

Read the markers and you know the shape of that block without opening a single signature: two lends, one write, one method on the receiver, one hand-off. That’s what making them visible buys. Running it prints:

Ada: 100
Ada: 145
closed with 145
doubled to 10, and fee is still 5
charged 5 and 10, and fee is still 5

Rules behind this page

When one owner isn’t enough

A value in Rask sits in one place and one name is responsible for it. That covers almost everything you write.

Three situations break it, and each has one answer: Shared, Rack with Link, and Heap. They have little in common, so there’s nothing to learn as a set. You go looking for one when you have its problem.

Most of the time: a plain field

struct Player {
    name: string
    health: i64
    items: Vec<string>
}
    mut hero = Player(name: "Ada", health: 100, items: [])
    hero.items.push("lamp")
    hero.health = hero.health - 10
    println("{hero.name} has {hero.health} hp and {hero.items.len()} item")

hero owns that player. The fields are reached with a dot, the Vec inside grows when you push to it, and when hero goes out of scope the whole thing is released. Nothing more is involved.

Having many of something doesn’t change that. A Vec or a Map is an ordinary value that happens to keep its contents on the heap, so it’s owned by one name like anything else.

So what breaks it? Another part of the program needs the same value. Or your values need to refer to each other. Or a type needs to contain itself.

When another part needs the same value: Shared

Say your settings are read in one place and updated in another. A copy for each is the wrong shape, because then there are two settings and an update to one is invisible to the other. What you want is a single value that both places reach.

struct Settings {
    theme: string
    retries: i64
}
    let settings = Shared.new(Settings(theme: "dark", retries: 3))

That’s the whole of it for most uses. Shared.new is always a correct answer: it takes a read-write lock, so any number of readers go at once and a writer gets it to itself. It’s safe to send to another task.

What you give up is reaching the value with a plain dot. Every access says read or write:

    let theme = settings.read().theme
    println("theme is {theme}")
    settings.write().retries = 2

Each of those takes the lock, does the one thing, and releases it, all inside the expression.

When you need several statements to happen under one lock, with holds it open for a block. Here the read and the write have to be the same lock, or another task could change retries in between:

    with settings.write() as s {
        if s.retries < 5 {
            s.retries = s.retries + 1
        }
    }

Taking a lock is a real cost, and a real cost is visible in Rask source. Scoping it puts the unlock where you can see it too: the block ends, the lock releases.

Shared takes a strategy that decides which lock it uses, and the default is the one above. The other two are worth reading about the day a profiler points at the lock, and not before.

Rooms with exits, nodes with children, anything where one value refers to another and values get removed while others still refer to them. A Vec can’t do this: the index you saved means something different after a removal, and nothing tells you.

struct Room {
    name: string
    exit: Link<Room>?
}

A Rack holds the values. A Link is how one of them refers to another.

    mut rooms: Rack<Room> = Rack.new()
    let hall = rooms.insert(Room(name: "hall", exit: none))
    let cell = rooms.insert(Room(name: "cell", exit: none))
    hall.exit = cell
    if hall.exit? as e {
        println("hall leads to {e.name}")
    }

Now delete the room that hall points at:

    rooms.delete(cell)

    if hall.exit? as e {
        println("hall leads to {e.name}")
    } else {
        println("hall leads nowhere")
    }

The same if, run twice, prints hall leads to cell and then hall leads nowhere.

That’s the part worth keeping. Deleting a node doesn’t leave hall.exit aiming at a dead value, and it doesn’t leave you an index that now means some other room. Every link into the deleted node reads as absent, so the if above simply takes its other branch.

Types that contain themselves: Heap

A Heap<T> puts the value somewhere else and keeps only its address.

That’s what a recursive type needs. A struct containing itself has no size: a Step holding a Step holds a Step, and the number never settles. An address doesn’t grow, so holding one settles it at once.

import memory.Heap

struct Step {
    label: string
    next: Heap<Step>?
}

func main() {
    let second = Heap(Step(label: "rinse", next: none))
    let first = Heap(Step(label: "wash", next: second))
    ensure drop(first)

    println("{(*first).label}")
    if (*first).next? as n {
        println("{(*n).label}")
    }
}

second is committed by the next line moving it into first, and storing it in that field hands it over for good: the chain belongs to first now, so releasing first releases all of it.

Heap is the one of the three you release by hand, because it’s the one that owns an allocation of its own with nothing around it to do the job. Rask has no destructors, so nothing runs behind your back at the closing brace. That’s the ensure drop(first) above.

The ensure goes immediately after the line that allocates, and the compiler holds you to it:

error[E0882]: `frame` has no cleanup committed yet
  --> docs/book/errors/beyond-one-owner/heap_no_cleanup.rk:14:5
    |
 12 |     let frame = Heap(Frame(label: "canvas", width: 1920))
    |     ----------------------------------------------------- `frame` was acquired here
   ...
 14 |     println("{(*frame).label}")
    |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^ this runs while `frame` has nothing scheduled to close it
    = fix: add `ensure drop(frame)` on the line after it's acquired, then use
          `frame` freely
    = why: a panic here would leak it — nothing is scheduled to clean it up, and
          there are no destructors to fall back on

That program does release the value, on the last line. The complaint is about the lines in between: a panic anywhere in them leaves with nothing scheduled to clean up. Putting the ensure first costs nothing, because consuming the value later cancels it.

Choosing

Four questions, asked in order. Stop at the first yes.

  1. One value, one owner? A plain field. Done.
  2. Many values? Vec or Map, unless:
  3. They refer to each other and can be deleted? Rack and Link.
  4. Does another part of the program need the same value, and does it change? Shared.

One more question sits outside that list, and asking it alongside the others is what makes these feel harder than they are: does the type contain itself? That’s Heap, and the answer doesn’t depend on any of the four above.

Reading it as one sentence: plain fields until you have many, Vec and Map until they refer to each other, Rack when they do, and a lock only once a second task exists. Steps 1 and 2 are most programs.

One thing to watch

Shared is not the only thing that holds a value still while you look at it. Reading an element of a Vec in a with block does the same thing:

    mut items: Vec<Item> = [Item(count: 1)]

    with items[0] as first {
        first.count = first.count + 1
        items.push(Item(count: 9))
    }

That doesn’t build, and the push is why:

error[E0808]: cannot push `items` inside `with` block
  --> docs/book/errors/beyond-one-owner/push_inside_with.rk:14:9
    |
 12 |     with items[0] as first {
    |     ------------- element borrowed here
   ...
 14 |         items.push(Item(count: 9))
    |         ^^^^^^^^^^^^^^^^^^^^^^^^^^ push not allowed inside with block
    = fix: move the structural mutation outside the with block
    = why: items can reallocate, invalidating the borrowed element. If the
          elements reference each other and get deleted individually, that is
          what a `Rack` is for — a node never moves, so nothing invalidates
          [mem.borrowing/W2]

Pushing can move the whole buffer somewhere else, which would leave the binding pointing at storage that isn’t the element any more. Note what’s frozen: the collection, not the element. You can read and write other elements freely. It’s growth that’s the problem, because growth is what moves things.

Running it

The Shared and Rack code above is one program, and it prints:

Ada has 90 hp and 1 item
theme is dark
retries now 3
hall leads to cell
hall leads nowhere

Rules behind this page

Examples

Complete programs, not fragments. Each one lives in examples/ with a recorded output in tests/golden/, which enrolls it in the example gate: every CI run compiles it on both backends and diffs what it prints. A program listed here works, or the build is red.

ProgramWhat it exercises
grep_clone.rkCLI flags, file reads, catch on the error branch, string scanning
file_copy.rkError enums with message(), optionals, own at a call site
game_loop.rkFrame update, interfaces, worker threads. Read it for the loop, not the storage (see below)
text_editor.rkUndo stack, ensure cleanup, linear resources. Same storage caveat
parameter_modes.rkBorrow, mutate, take. The subject of Passing Values

Run one:

rask run examples/grep_clone.rk -- -n pattern file.txt

There were walkthrough chapters here. They quoted code by hand, were checked only for parsing, and drifted: the game-loop page taught Pool<T>, which the design replaced with racks and links. Guide chapters now pull their code out of these programs (how this book is built), so a walkthrough can’t say something the program doesn’t do.

Formal Specifications

The formal language specifications are maintained in the repository’s specs/ directory. These are detailed technical documents for language implementers and those who want deep understanding.

View Specifications →

Organization

Specs are organized by topic:

  • Types - Type system, generics, interfaces
  • Memory - Ownership, borrowing, resources
  • Control - Loops, match, comptime
  • Concurrency - Tasks, threads, channels
  • Structure - Modules, packages, builds
  • Stdlib - Standard library APIs

Quick Access

Key specifications:

TopicLink
Ownershipownership.md
Borrowingborrowing.md
Collectionscollections.md
Racks and Linksracks.md
Error Typeserror-types.md
Concurrencyasync.md

For Users vs Implementers

  • This Book - User-facing documentation (“How do I use Rask?”)
  • Specs - Formal specifications (“How does Rask work internally?”)

Most Rask users won’t need the specs. If you’re:

  • Building applications → This book is for you
  • Building compilers/tools → Read the specs
  • Curious about internals → Specs provide complete detail

See Also

Contributing

Rask is in active design and development. Contributions welcome!

How to Help

  • Try it out - Run examples, report bugs
  • Review specs - Provide feedback on language design
  • Implement features - Check TODO.md for open tasks
  • Documentation - Improve this book

Getting Started

  1. Read the Design Process to understand Rask’s philosophy
  2. Check out the formal specifications
  3. Explore the CORE_DESIGN.md document
  4. Look at TODO.md for what needs work

Repository

github.com/rask-lang/rask

Ways to Contribute

Bug Reports

Found a bug? Open an issue.

Include:

  • Code that demonstrates the bug
  • Expected behavior
  • Actual behavior
  • Compiler output

Feature Suggestions

Have an idea? Open an issue for discussion.

Consider:

  • Does it align with core principles?
  • What’s the tradeoff?
  • How does it affect the litmus tests?

Code Contributions

  1. Fork the repository
  2. Create a feature branch
  3. Make your changes
  4. Run tests: cd compiler && cargo test
  5. Submit a pull request

Documentation

Improvements to this book are welcome:

  • Fix typos and clarity issues
  • Add examples
  • Improve explanations
  • Expand placeholder sections

Community Guidelines

  • Be respectful and constructive
  • Focus on technical merit
  • Consider tradeoffs and design constraints
  • Test your changes

Questions?

Open a discussion or issue on GitHub.

Design Process

Rask’s design is guided by clear principles and measured against specific metrics.

Design Principles

  1. Safety Without Annotation - Memory safety without lifetime markers
  2. Value Semantics - No hidden sharing or aliasing
  3. No Storable References - References can’t escape scope
  4. Transparent Costs - Major costs visible in code
  5. Local Analysis Only - No whole-program inference
  6. Resource Types - I/O handles must be consumed
  7. Compiler Knowledge is Visible - IDE shows inferred information

Full details: CORE_DESIGN.md

Validation

Rask is validated against test programs that must work naturally:

  1. HTTP JSON API server ✓ (passes type-checking)
  2. grep clone ✓ (implemented)
  3. Text editor with undo ✓ (implemented)
  4. Game loop with entities ✓ (implemented)
  5. Embedded sensor processor ✓ (passes type-checking)

Litmus test: If Rask is longer/noisier than Go for core loops, fix the design.

Metrics

Design decisions are evaluated using concrete metrics:

  • Clone overhead (% of lines with .clone())
  • Graph edge-write cost (nanoseconds)
  • Compile times (seconds per 1000 LOC)
  • Binary size
  • Memory usage

See METRICS.md for the scoring methodology.

Specs and RFCs

Language features are documented as formal specifications in specs/.

Major changes follow an RFC process:

  1. Open an issue for discussion
  2. Draft a specification
  3. Implement in compiler
  4. Validate against litmus tests
  5. Update metrics
  6. Merge if it improves the design

Tradeoffs

Every design has tradeoffs. Rask makes these intentional choices:

  • More .clone() calls - Better than lifetime annotations (our view)
  • An edge write touches the target too - Better than raw pointers with manual tracking
  • No storable references - Simpler mental model, requires restructuring some patterns
  • Explicit costs - Better than hidden complexity

See CORE_DESIGN.md § Tradeoffs for full discussion.

Contributing to Design

When proposing changes:

  1. Explain the problem - What use case is difficult today?
  2. Show the tradeoff - What does this cost?
  3. Test against litmus tests - Does it make real programs better or worse?
  4. Measure the impact - Update relevant metrics
  5. Consider alternatives - What other approaches exist?

The goal is ergonomics without hidden costs. If a feature hides complexity or breaks transparency, it probably doesn’t belong.

Philosophy

“Safety is a property, not an experience.”

Users shouldn’t think about memory safety—they should just write code. The type system and scope rules make unsafe operations impossible by construction.

“If Rask needs 3+ lines where Go needs 1, question the design.”

Ceremony should be minimal. Explicit costs are good; boilerplate is bad.

“Local analysis only.”

Compilation should scale linearly. No whole-program inference, no escape analysis. Function signatures tell the whole story.

Learn More

How this book is built

Rules for anyone adding a page. They exist because a book is a second copy of the language, and the usual fate of a second copy is to drift until it teaches a version of the language nobody ships.

What a page may contain

Prose, and code that something else already verifies. Nothing else.

You want to showWrite it as
A few lines from a real program{{#include ../path/prog.rk:anchor}}
A small self-contained snippetan inline block with <!-- test: compile --> or <!-- test: run | expected output -->
What the compiler says when you get it wrong{{#include ../../errors/<chapter>/<case>.out}}
What a program prints{{#include ../../../../tests/golden/<name>.out}}

<!-- test: parse --> does not count as verified anywhere in the book. Parsing proves the syntax is current; syntax is not what rots. The front-page snippet passed test: parse while calling fs.open, which the real grep program doesn’t use, and missing both its imports, so a reader copying it got two errors on page one. compile type-checks; run | expected runs it and matches the output. Use one of those.

CI runs the markers in its “Docs snippets parse” step, which has covered docs/ for a while. What it can’t tell you is whether a block has a marker at all, or whether the one it has is strong enough, which is what tests/book_gate.sh is for. It also checks that every include resolves: the file is present and the named anchor really is in it, because mdBook renders a missing anchor as nothing at all and says so quietly.

Errors are content

Chapters teach with real compiler output. Each case is a program under docs/book/errors/<chapter>/ that must not compile, with its rask check output pinned beside it. The gate re-renders and diffs on every run, so improving a diagnostic shows up as a diff on the page that teaches it, which is the review you want. A case that starts compiling is a hard failure, not a stale golden: the page is claiming a rejection the compiler no longer makes.

Regenerate after a deliberate diagnostics change:

tests/book_gate.sh --update

How to explain it

The machine is the metaphor. Describe what actually happens in memory, in plain words, and don’t build a story on top of it.

Lending is the cautionary case. In a library the book leaves the shelf; in Rask the caller goes on reading the value for the whole call. A reader who builds the library model predicts the wrong thing at their first mutate call — and learned it here.

Rask can afford the literal route where most languages can’t. No lifetime annotations, no reference type, no effects: every rule is a claim about bytes and where they sit. So make the claim.

One noun carries it: storage. A place in memory holding a value. A name reaches storage, a move hands it over, a borrow reads storage the function doesn’t own, a copy makes a second one. Define it once and don’t introduce a parallel vocabulary three chapters later.

Idea first, word second. Build the model in plain language, then attach the term in one sentence, as a hook for the error messages:

A function that takes a value without take reads the caller’s storage where it sits — it doesn’t get a copy, and it doesn’t get to keep it. The compiler calls this borrowing, and you’ll see the word in error messages.

Jargon is a label for something the reader already has. Arriving before the idea is what makes a language feel like it was written for people who already know it.

Spend a metaphor only where the literal thing isn’t observable. In Rask that’s almost nowhere, which is the design working rather than a style choice. Every spend should feel like a defeat.

The test for a paragraph: could a reader predict the next error message from what it just told them? If they’d have to re-derive it from a metaphor first, the paragraph is doing the wrong job.

No “I”, and no decisions. The style guide keeps first person for design writing, where someone is owning a tradeoff. A chapter isn’t that. It explains a language that already works, to someone trying to use it, so it says what the rule is and why it holds: “taking a lock is a real cost, so it belongs in the source where it happens”, never “I picked scoped access over making a shared value feel like a plain one”. Same reasoning, no author in the room.

The difference from the section below is worth keeping straight. The why belongs in a chapter. The choosing doesn’t: alternatives that were weighed, what was rejected and what it would have cost are design writing, and they live in specs/ and RULINGS.md. A reader learning Shared needs the reason the lock is visible. They don’t need to know it was ever up for debate.

Write the language, not the compiler’s current mood

A chapter describes the language as it’s designed. A bug in one backend is a bug, not a smaller language, and it never decides what a page is allowed to teach.

When the two backends disagree the interpreter is the reference, so a block that runs correctly there is correct. Mark it <!-- test: run-interp | expected -->, which exists for this, and file the native gap. The block stays verified, the chapter stays honest, and the marker is what the fix later deletes.

What this rules out is quietly teaching less. The scoped-access chapter dropped recursive Heap for a day because the recursive case doesn’t lower on native (#1234), and what the page taught instead was a smaller language that happens to match one backend’s bugs. A reader can’t tell the difference between “Rask doesn’t do this” and “Rask does this and the compiler is behind”, and only one of those is true.

Caveats about what’s broken don’t belong in a chapter either. They date instantly and they’re the issue tracker’s job.

The book teaches the ruling, not just the rule

The specs are normative and say what the rule is. A chapter’s job is the part the spec tables leave out: why it holds. “The marker is required” is a spec line. “A misread move is caught for you and a misread mutation isn’t, so the one that can’t be caught is the one you write down” is a chapter.

Note what that example doesn’t do: it never mentions the alternative, or that anyone chose. It gives the reader the reason and stops. That’s the line between a why, which belongs here, and a decision, which belongs in specs/.

specs/RULINGS.md is where those arguments come from. If a chapter can’t say why, it’s restating the spec and should link to it instead.

Size is capped by the language, not by the author

specs/DAY_ONE.md holds the reading set: the concepts you need to read someone else’s Rask, which must fit one page. The guide inherits that budget: roughly one chapter per Day-One concept, and no chapter for something that isn’t in the language yet.

This is the answer to books that grow to a thousand pages: the guide can’t outgrow the language, because its table of contents is the language’s own list. Anything deeper goes to specs/, which is allowed to be long.

What to do when the language changes

Nothing, usually. That’s the point: included code moves with its program, error renderings are regenerated by the gate, program output comes from the goldens. The prose only needs touching when a decision changes, which is rare and deliberate.

If you find yourself keeping a page in step by hand, the page is built wrong.