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.
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, http | no filesystem or sockets |
time | no clock — which is also why benchmark blocks don’t run here |
spawn { }, Thread.spawn { }, ThreadPool.spawn { }, using Multitasking, using ThreadPool | no threads |
extern "C", import c | no 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
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
| Command | What 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
- Passing Values, the first guide chapter
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 pointprintln()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))
}
letbinds a name once: no reassignment, and no mutating the value either (you can still move it — handing ownership away isn’t mutation)mutis what you reach for when the value needs to change — reassignment or a mutating method likev.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 theErrorinterface - 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
- Parameter modes: the normative version
- Value semantics: the copy threshold
- Linearity: why a borrow can’t be consumed
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.
Things that point at each other: Rack and Link
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.
- One value, one owner? A plain field. Done.
- Many values?
VecorMap, unless: - They refer to each other and can be deleted?
RackandLink. - 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
- Shared, Rack and Heap: all three, and why you can’t write your own
- Racks: links, deletion, and what a stale one reads as
- Synchronization: the three strategies
- Borrowing: inline access and
with - Linearity: why
Heapneeds anensure
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.
| Program | What it exercises |
|---|---|
| grep_clone.rk | CLI flags, file reads, catch on the error branch, string scanning |
| file_copy.rk | Error enums with message(), optionals, own at a call site |
| game_loop.rk | Frame update, interfaces, worker threads. Read it for the loop, not the storage (see below) |
| text_editor.rk | Undo stack, ensure cleanup, linear resources. Same storage caveat |
| parameter_modes.rk | Borrow, 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.
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:
| Topic | Link |
|---|---|
| Ownership | ownership.md |
| Borrowing | borrowing.md |
| Collections | collections.md |
| Racks and Links | racks.md |
| Error Types | error-types.md |
| Concurrency | async.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
- CORE_DESIGN.md - Design philosophy and rationale
- METRICS.md - How the design is validated
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
- Read the Design Process to understand Rask’s philosophy
- Check out the formal specifications
- Explore the CORE_DESIGN.md document
- Look at TODO.md for what needs work
Repository
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
- Fork the repository
- Create a feature branch
- Make your changes
- Run tests:
cd compiler && cargo test - 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
- Safety Without Annotation - Memory safety without lifetime markers
- Value Semantics - No hidden sharing or aliasing
- No Storable References - References can’t escape scope
- Transparent Costs - Major costs visible in code
- Local Analysis Only - No whole-program inference
- Resource Types - I/O handles must be consumed
- Compiler Knowledge is Visible - IDE shows inferred information
Full details: CORE_DESIGN.md
Validation
Rask is validated against test programs that must work naturally:
- HTTP JSON API server ✓ (passes type-checking)
- grep clone ✓ (implemented)
- Text editor with undo ✓ (implemented)
- Game loop with entities ✓ (implemented)
- 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:
- Open an issue for discussion
- Draft a specification
- Implement in compiler
- Validate against litmus tests
- Update metrics
- 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:
- Explain the problem - What use case is difficult today?
- Show the tradeoff - What does this cost?
- Test against litmus tests - Does it make real programs better or worse?
- Measure the impact - Update relevant metrics
- 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
- CORE_DESIGN.md - Complete design rationale
- METRICS.md - How we measure success
- TODO.md - What’s being worked on
- Formal Specifications - Detailed technical specs
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 show | Write it as |
|---|---|
| A few lines from a real program | {{#include ../path/prog.rk:anchor}} |
| A small self-contained snippet | an 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
takereads 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.