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.

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 pre-0.1 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 writing is the long-form argument; CORE_DESIGN.md is the principles it falls out of, and RULINGS.md is how open questions get settled.

Installation

Note: Rask is in early development (pre-0.1). 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: Passing Values, on what happens to a value when you hand it to a function.

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, using Multitasking, using ThreadPoolno threads
extern "C", import cno libc to call

Everything else runs: collections, structs, enums, generics, traits, 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

Passing Values

Every parameter answers one question: does the caller still have this afterwards? There are three answers, and the code says which one at both ends.

Borrow: the default

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

No marker, no ceremony. The callee reads; the caller keeps the value and carries on using it. This is most parameters in most programs, which is why it’s the default.

A borrow isn’t yours to give away. 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 marked this as given, 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.

Mutate: write through, caller keeps it

// `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, …)
    = 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.

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.

Take: the callee keeps it

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

No marker needed, though own account is available when you want the call site to shout. After the call the name is 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: the compiler will tell you exactly where the value went, the moment you reach for it again.

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 borrows, one mutation, 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

Small values are copied, not given

func charge_twice(take amount: i64) -> i64 {
    return amount * 2
}
    // Small values (16 bytes or less, all-Copy fields) copy instead of moving,
    // so handing one to `take` leaves the caller's copy alone.
    let fee = 5
    let doubled = charge_twice(fee)
    println("doubled to {doubled}, and fee is still {fee}")

take means “I’m keeping this”, but you can’t take away what the caller never gave up. Values of 16 bytes or less whose fields are all Copy get copied on the way in, so fee is untouched. Past 16 bytes it’s a real move and the name dies, which is what the Account error above shows, sizes and all.

The threshold is fixed at 16 bytes and isn’t configurable. Moving it would change what existing programs mean, so it’s a semantic boundary rather than a tuning knob.

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, traits, 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

Two of these store their entities in a Pool with Handles, which racks and links have replaced. That’s sequencing rather than neglect: migrating the examples is step 5 of #908, and it waits on an answer for serialization: a handle is an integer that survives a round trip, and a link is an address, so there’s no link analogue yet. Copy the loop and the undo stack from these; take the storage pattern from racks.md.

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, traits
  • 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
Poolspools.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())
  • Handle access 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)
  • Handle overhead - 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

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’s that way, and what it would cost to be otherwise. “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.

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.