The Kāra Programming Language
Welcome to The Kāra Book — the official guide to learning the Kāra programming language.
What is Kāra?
Kāra is a systems programming language where you declare what and why, and the compiler decides how. You write sequential-looking code with clear intent, and the compiler infers borrow lifetimes, optimizes memory layout, and parallelizes independent work — without explicit annotations.
The language is built on four layers, in order of importance:
- Values and types define structure.
- Effects define observable behavior.
- Ownership defines aliasing and lifetimes.
- Layout defines physical memory representation.
If you're coming from Rust, think of Kāra as a language that shares many of the same safety goals but takes a different path — owned by default with explicit ref / mut ref modes (no <'a> lifetime parameters), and using an effect system to track side effects and enable parallelization of independent work.
If you're coming from Python, Go, or TypeScript, Kāra will feel familiar in syntax while giving you performance and safety guarantees that those languages can't offer.
What this book covers
This book walks through the language from first principles. You don't need prior systems programming experience, though familiarity with any typed language will help.
- Getting Started covers the basics: variables, functions, control flow.
- Core Concepts introduces structs, enums, pattern matching, error handling, traits, and generics.
- What Makes Kāra Different digs into the features that set Kāra apart: the effect system, ownership without lifetime annotations, and the module system.
- Advanced Topics covers concurrency, data layout control, and testing.
Each chapter builds on the ones before it, with code examples you can run.
Who is this for?
Anyone who wants to write fast, safe code without fighting the compiler. Whether you're building a web server, a CLI tool, an embedded system, or a data pipeline — Kāra is designed to get out of your way while keeping your programs correct.
Let's get started.
Hello, Kāra
Every journey starts with a first program. Here's yours:
fn main() {
println("Hello, world!");
}
Let's break it down:
fn main()declares the program's entry point. Every Kāra executable starts here.println(...)prints a line to standard output. It's available everywhere — no imports needed.- Semicolons end statements. Braces delimit blocks. If you've used C, Rust, Go, or Java, this feels familiar.
A slightly bigger program
fn greet(name: String) {
println(f"Hello, {name}!");
}
fn main() {
greet("Kāra");
greet("world");
}
A few things to notice:
- Functions are declared with
fn, parameters arename: Type. - String interpolation uses
f"..."with{expr}inside. No format macros, no concatenation — just prefix the string withfand write expressions in braces. - No
returnneeded. The last expression in a block is its value. You can usereturnfor early exits, but for the common case you just write the value.
Comments
// This is a line comment.
/* This is a block comment.
Block comments /* can nest */. */
What you get for free
Even in this tiny program, the Kāra compiler is doing work behind the scenes:
- Effect inference:
greetwrites to stdout viaprintln. The compiler knows this — it infers awrites(Stdout)effect. You didn't have to declare it becausegreetisn't a public API function. - Ownership feedback:
nameis declaredString(owned by default). Since the body only reads it,karac explain greetwill suggest tightening the signature toref String— the compiler doesn't change your signature, but it tells you when a tighter mode would also work.
You don't need to understand effects or ownership yet. The point is that the compiler is your partner from the very first line of code — quietly making good decisions so you can focus on what your program does.
We'll explore both systems in depth in later chapters.
Getting Started, Part 2: Two Surfaces
Kāra is one language with two everyday surfaces. You can save your code in a .kara file and run it with karac run, or you can paste it line by line into karac repl and watch each piece take effect immediately. Both surfaces run the same compiler, see the same diagnostics, and apply the same ownership rules. The difference is the rhythm: a file is a finished thought, the REPL is a thought in progress.
This chapter walks one example — a binary search over a sorted vector — through both surfaces side by side, so you can feel where each one shines.
Try without installing. A browser playground at https://play.kara-lang.org runs the same compiler in your browser. If you'd rather read along than install, paste any example from this chapter there and the diagnostics will match what you'd see locally.
The same program, on disk
Save this to search.kara:
fn binary_search(haystack: ref Vec[i32], needle: i32) -> Option[usize] {
let mut lo: usize = 0;
let mut hi: usize = haystack.len();
while lo < hi {
let mid = (lo + hi) / 2;
let value = haystack[mid];
if value == needle {
return Some(mid);
} else if value < needle {
lo = mid + 1;
} else {
hi = mid;
}
}
None
}
fn main() {
let nums = vec![1, 3, 5, 7, 9, 11, 13];
match binary_search(ref nums, 7) {
Some(i) => println(f"found at index {i}"),
None => println("not found"),
}
}
Then run it:
$ karac run search.kara
found at index 3
A few things worth pointing at:
ref Vec[i32]says "I want to read this vector, not take ownership of it." The caller keepsnumsand can use it again afterward.Option[usize]is the standard "maybe an index" return. Pattern-match on it; the compiler will warn you if you forget a case.- No allocator imports, no module declarations. A
.karafile withfn main()is a complete program.
The same program, in the REPL
Now start the REPL:
$ karac repl
Kāra REPL — :help for commands, :quit to exit.
karac>
We'll build the same example cell by cell. Each line you submit is a cell — its own unit of evaluation, kept around so later cells can see it.
karac> fn binary_search(haystack: ref Vec[i32], needle: i32) -> Option[usize] {
... let mut lo: usize = 0;
... let mut hi: usize = haystack.len();
... while lo < hi {
... let mid = (lo + hi) / 2;
... let value = haystack[mid];
... if value == needle { return Some(mid); }
... else if value < needle { lo = mid + 1; }
... else { hi = mid; }
... }
... None
... }
karac> let nums = vec![1, 3, 5, 7, 9, 11, 13];
karac> binary_search(ref nums, 7)
Some(3)
That last line — a bare expression with no let — is shown as a value. The REPL prints Some(3) because that's what the expression evaluated to. Compare this to the file version, which had to wrap the result in match and println to see it.
Cells remember each other
The fn binary_search declaration is a pure-items cell: it adds a function to the session. Later cells can call it without redefining it. Same for let nums = … — that binding stays in scope for every cell that follows.
karac> binary_search(ref nums, 100)
None
karac> binary_search(ref nums, 5)
Some(2)
nums is still here. So is binary_search. The REPL holds onto your work the same way a file's top-to-bottom order does, just one cell at a time.
Re-declaring is allowed
You don't have to invent new names for retries:
karac> let nums = vec![10, 20, 30, 40, 50];
karac> binary_search(ref nums, 30)
Some(2)
The second let nums shadows the first — same name, fresh binding. The old vector is dropped at the moment you re-declare. This is what you'd want: experimenting in the REPL shouldn't pile up nums1, nums2, nums_v3 in your head.
Ownership crosses cells, honestly
This is the part most REPLs cheat on. They evaluate each cell in isolation and pretend ownership doesn't exist. Kāra doesn't pretend.
karac> let owned = vec![1, 2, 3];
karac> let sum: i32 = owned.iter().sum();
karac> println(f"sum={sum}, owned still here: {owned.len()}");
sum=6, owned still here: 3
owned.iter().sum() borrows; the original is still yours. But:
karac> let s: String = "hello".to_string();
karac> let taken = s;
karac> println(s);
error: use of moved value `s`
--> cell 3:1
|
1 | println(s);
| ^ value moved into `taken` in cell 2
= the move happened in a previous cell; this cell sees the post-move state.
= consider `let taken = s.clone();` if you need both bindings.
The diagnostic doesn't just say moved — it tells you which cell the move happened in and suggests a fix. This is the UseAfterMove notebook-aware tail at work; ownership in the REPL behaves exactly like ownership in a file, but the diagnostics know about your cell history.
Teaching ownership honestly from day one matters: when you graduate from REPL doodles to compiled .kara files, nothing has to be un-learned.
Meta-commands
The REPL ships with a handful of :command helpers. Two are worth knowing right away.
:effects — what does this session touch?
karac> fn read_config() -> String {
... std::fs::read_to_string("config.toml").unwrap()
... }
karac> :effects
session effects: reads(Files), panics
read_config: reads(Files), panics
Every function the session knows about, every effect it carries. This is the same effect analysis that the compiler runs on .kara files — you're just getting a live readout instead of waiting for a diagnostic to fire. We'll cover the effect system properly in chapter 11; for now, treat :effects as a "what would I have to declare if this were a public API" lens.
:save — graduate to a file
When the REPL session has earned its keep, hand it off to disk:
karac> :save search.kara
wrote search.kara (4 cells, 1 fn, 1 let)
:save writes a real .kara file. The session's items become top-level definitions, the cell history becomes the body of fn main(), and any :provide scopes you opened are emitted as with_provider[R](…) { … } blocks. The file karac runs without modification.
This is the natural lifecycle: prototype in the REPL, :save when the shape feels right, edit the file from there. The same compiler runs both ends; nothing gets translated.
Which surface, when?
Both surfaces are first-class. As a rough rule:
- Files for anything with a real
main, anything you'll come back to next week, anything you'd put under version control. They're cheap to start —fn main() { … }is the whole ceremony. - REPL for learning the language, exploring a new crate, sanity-checking a one-liner, or shaping a function whose signature you're not sure about yet.
:effectsand the cell-history-aware diagnostics make it a teaching surface, not just a calculator.
Reach for whichever feels right. The compiler doesn't care which surface called it — your code's behavior is the same either way.
What's next
You've seen Kāra running. The next few chapters introduce the building blocks — variables and types, functions, control flow — using whichever surface fits each example. We'll mostly show file form, because it's easier to read on a page, but every example also runs in the REPL if you'd rather experiment.
Variables and Types
Bindings
Variables in Kāra are declared with let:
let x = 42;
let name = "Kāra";
let pi = 3.14159;
let active = true;
The compiler infers the type from the value. You can also annotate explicitly:
let x: i32 = 42;
let name: String = "Kāra";
Mutability
Bindings are immutable by default. To allow reassignment, use let mut:
let x = 5;
// x = 10; // compile error: x is immutable
let mut y = 5;
y = 10; // ok
This is a deliberate default. Most values don't need to change, and immutability helps the compiler reason about your code — for ownership, for parallelization, and for correctness.
Primitive types
Kāra has the numeric types you'd expect:
| Type | Description |
|---|---|
i8, i16, i32, i64 | Signed integers |
u8, u16, u32, u64 | Unsigned integers |
f32, f64 | Floating-point numbers |
bool | true or false |
char | A Unicode scalar value: 'a', '\n', '\u{1F600}' |
Integer literals default to i64, floats to f64. If you annotate a different type, the compiler checks that the literal fits:
let small: u8 = 255; // ok
// let overflow: u8 = 256; // compile error: 256 doesn't fit in u8
Numeric literals
Numbers can use underscores for readability and different bases:
let million = 1_000_000;
let flags = 0b1010_0011; // binary
let color = 0xFF_AA_00; // hex
let permissions = 0o755; // octal
Strings
Kāra has two string types:
String— an owned, heap-allocated UTF-8 string.StringSlice— a borrowed view into a string (like Rust's&str).
let greeting = "Hello"; // String
let multiline = """
This is a
multi-line string.
""";
String interpolation
Prefix a string with f to embed expressions:
let name = "world";
let msg = f"Hello, {name}!";
let x = 10;
let y = 20;
println(f"{x} + {y} = {x + y}"); // "10 + 20 = 30"
Type conversions
Kāra does not do implicit type conversions (except narrowing integer literals to annotated types). Use as for numeric casts:
let x: i64 = 1000;
let y: i32 = x as i32;
Shadowing
You can re-declare a binding with the same name. The new binding shadows the old one:
let x = 5;
let x = x + 1; // x is now 6
let x = x * 2; // x is now 12
Shadowing lets you transform a value through a series of steps without mut. Each let creates a new binding — the old one is gone.
Naming identifiers
Kāra enforces identifier naming at the compiler level — it's a grammar rule, not a style guide. Every identifier belongs to one of three case classes:
- Type class — PascalCase. Structs, enums, enum variants, traits, generic type parameters:
String,UserAccount,IoError,T. - Value class — snake_case, or a leading
_for intentionally-unused bindings. Functions, parameters, fields, modules, andletbindings inside function bodies:read_to_string,user_count,_tmp. - Const class — ALL_UPPER with underscores. Module-level
letandlet mutbindings:MAX_RETRIES,TIMEOUT_MS.
struct UserAccount { ... } // Type class
fn read_to_string(path: String) { ... } // Value class
let count = 0; // Value class (function body)
fn ReadFile(), struct user_account, and let pi = 3.14 at module scope are all compile errors. One quirk worth knowing: multi-word types treat acronyms as words — HttpClient, not HTTPClient; IoError, not IOError. That keeps the classification unambiguous at a glance.
Note: _ on its own isn't a name — it's a wildcard used in patterns, let _ = expr, pipes, and with _ effects. Only leading _ (like _tmp) is a valid identifier you can read later.
The point of enforcing this is that every Kāra codebase reads the same way — no per-project casing debates, no PR bikeshedding, no re-tuning when you move between libraries.
Module-level bindings
You can declare bindings at the top of a file, outside any function:
let MAX_RETRIES: i32 = 5;
let TIMEOUT_MS: i64 = 60 * 1000;
let APP_NAME: String = "myapp";
Module-level bindings must be initialized with compile-time constant expressions — no function calls, no I/O, no allocations. This is a deliberate restriction: there's no hidden code running before main, no initialization order bugs, no startup effects you can't see.
Values that need runtime initialization (config files, database connections) are constructed inside main and passed down. We'll cover this pattern in later chapters.
Functions
Declaring functions
Functions are declared with fn, parameters are name: Type, and the return type follows ->:
fn add(a: i64, b: i64) -> i64 {
a + b
}
fn greet(name: String) {
println(f"Hello, {name}!");
}
- The last expression in the body is the return value. No
returnkeyword needed. - If a function doesn't return a value, omit the
-> Type. - Use
returnfor early exits:
fn first_positive(numbers: Vec[i64]) -> Option[i64] {
for n in numbers {
if n > 0 {
return Some(n);
}
}
None
}
Expressions, not statements
Almost everything in Kāra is an expression that produces a value. if/else is an expression:
fn abs(x: i64) -> i64 {
if x >= 0 { x } else { -x }
}
match is an expression:
fn describe(n: i64) -> String {
match n {
0 => "zero",
1..=9 => "single digit",
_ => "big number",
}
}
This means you rarely need temporary variables — you can use control flow inline wherever a value is expected.
Parameter modes: the compiler helps
Here's a function that reads a string but doesn't consume it:
fn count_words(text: String) -> u64 {
text.split(' ').len()
}
You wrote text: String, but the compiler notices that text is only read, never moved or mutated. It automatically infers that text should be passed by reference. The caller doesn't make a copy; count_words borrows the string.
You can also be explicit:
fn count_words(text: ref String) -> u64 {
text.split(' ').len()
}
Both versions behave identically. The inference just saves you the annotation. We'll cover ownership in depth in Chapter 12.
Methods
Functions can be attached to types using impl blocks:
struct Circle {
radius: f64,
}
impl Circle {
fn area(ref self) -> f64 {
3.14159 * self.radius * self.radius
}
fn scale(mut ref self, factor: f64) {
self.radius = self.radius * factor;
}
fn new(radius: f64) -> Circle {
Circle { radius }
}
}
ref self— the method borrows the value (reads only).mut ref self— the method borrows mutably (can modify fields).- No
selfparameter — it's an associated function (like a static method). Call it asCircle.new(5.0).
Methods use Universal Function Call Syntax (UFCS). These two calls are the same:
let c = Circle.new(5.0);
c.area() // method syntax
Circle.area(c) // function syntax — same thing
Control Flow
if / else
if is an expression — it produces a value:
let status = if score >= 90 { "excellent" } else { "keep going" };
For side effects, use it as a statement:
if temperature > 100 {
println("Warning: overheating!");
} else if temperature > 80 {
println("Running warm.");
} else {
println("All good.");
}
No parentheses around the condition. Braces are always required.
Loops
while
let mut count = 0;
while count < 10 {
println(count);
count = count + 1;
}
for loops
for iterates over anything iterable:
let names = ["Alice", "Bob", "Charlie"];
for name in names {
println(f"Hello, {name}!");
}
With ranges:
// 0, 1, 2, 3, 4
for i in 0..5 {
println(i);
}
// 0, 1, 2, 3, 4, 5 (inclusive)
for i in 0..=5 {
println(i);
}
loop
An infinite loop. Use break to exit:
let mut attempt = 0;
let result = loop {
attempt = attempt + 1;
if try_connect() {
break "connected";
}
if attempt >= 3 {
break "failed";
}
};
loop is an expression — break value sets the value of the whole loop.
break and continue
for i in 0..100 {
if i % 2 == 0 {
continue; // skip even numbers
}
if i > 10 {
break; // stop after 10
}
println(i); // prints 1, 3, 5, 7, 9
}
match
Pattern matching is one of the most powerful tools in Kāra. At its simplest, it's a better switch:
let day = 3;
let name = match day {
1 => "Monday",
2 => "Tuesday",
3 => "Wednesday",
4 => "Thursday",
5 => "Friday",
6 | 7 => "Weekend",
_ => "Invalid",
};
But match goes far beyond this — it works with enums, structs, nested data, and guards. We'll cover it fully in Chapter 6.
The pipe operator
Kāra has a pipe operator |> for chaining function calls left-to-right:
let result = data
|> transform
|> validate
|> save;
This is equivalent to save(validate(transform(data))) but reads in the order things happen. It's especially useful for data processing pipelines.
Structs and Enums
Structs
A struct groups related data together:
struct Point {
x: f64,
y: f64,
}
fn main() {
let origin = Point { x: 0.0, y: 0.0 };
let p = Point { x: 3.0, y: 4.0 };
println(f"({p.x}, {p.y})");
}
Struct names are Type-class identifiers (PascalCase); field names are Value-class (snake_case). The compiler enforces both — see Naming identifiers in chapter 2.
Methods on structs
Use impl blocks to attach behavior:
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
fn area(ref self) -> f64 {
self.width * self.height
}
fn is_square(ref self) -> bool {
self.width == self.height
}
fn new(width: f64, height: f64) -> Rectangle {
Rectangle { width, height }
}
}
fn main() {
let r = Rectangle.new(10.0, 5.0);
println(f"Area: {r.area()}");
println(f"Square? {r.is_square()}");
}
Tuple structs
For lightweight wrappers:
struct Meters(f64);
struct Seconds(f64);
These are distinct types — you can't accidentally pass Meters where Seconds is expected.
Enums
An enum defines a type that can be one of several variants:
enum Direction {
North,
South,
East,
West,
}
fn describe(d: Direction) -> String {
match d {
Direction.North => "going up",
Direction.South => "going down",
Direction.East => "going right",
Direction.West => "going left",
}
}
Enums with data
Variants can carry data — this is what makes Kāra enums algebraic data types:
enum Shape {
Circle(f64), // radius
Rectangle(f64, f64), // width, height
Triangle { a: f64, b: f64, c: f64 }, // named fields
}
fn area(shape: Shape) -> f64 {
match shape {
Shape.Circle(r) => 3.14159 * r * r,
Shape.Rectangle(w, h) => w * h,
Shape.Triangle { a, b, c } => {
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
}
}
Option and Result
Two enums are so fundamental they're in the prelude — available everywhere without import:
enum Option[T] {
Some(T),
None,
}
enum Result[T, E] {
Ok(T),
Err(E),
}
Option represents a value that might not exist. Result represents an operation that might fail. You'll use them constantly:
fn find_user(id: u64) -> Option[User] {
// returns Some(user) or None
}
fn parse_number(s: String) -> Result[i64, ParseError] {
// returns Ok(number) or Err(error)
}
We'll cover error handling patterns in depth in Chapter 7.
Shared types
By default, structs and enums have value semantics — assigning or passing them moves or copies the data. For types that need reference semantics (shared ownership, graph structures), prefix with shared:
shared struct Node {
value: i64,
children: Vec[Node],
}
A shared struct is automatically reference-counted. Multiple owners can point to the same data without explicit Rc or Arc wrappers. The compiler picks the right reference-counting strategy behind the scenes.
Use shared when your data naturally has multiple owners. Use regular structs (the default) for everything else.
Pattern Matching
Pattern matching is one of Kāra's most expressive features. The match expression lets you destructure data and branch on its shape — and the compiler guarantees you handle every case.
Basic matching
fn classify(n: i64) -> String {
match n {
0 => "zero",
1 | 2 | 3 => "small",
4..=9 => "medium",
_ => "large",
}
}
|matches multiple values...=matches inclusive ranges._is the wildcard — matches anything.
Destructuring enums
This is where match really shines:
enum Message {
Quit,
Echo(String),
Move { x: i64, y: i64 },
}
fn handle(msg: Message) {
match msg {
Message.Quit => println("Goodbye."),
Message.Echo(text) => println(f"Echo: {text}"),
Message.Move { x, y } => println(f"Moving to ({x}, {y})"),
}
}
Each variant's data is extracted directly into variables. No casting, no type-checking at runtime — the compiler knows the structure at compile time.
Destructuring structs
struct Point {
x: f64,
y: f64,
}
fn describe(p: Point) -> String {
match p {
Point { x: 0.0, y: 0.0 } => "origin",
Point { x, y: 0.0 } => f"on the x-axis at {x}",
Point { x: 0.0, y } => f"on the y-axis at {y}",
Point { x, y } => f"at ({x}, {y})",
}
}
Nested patterns
Patterns compose. Match on the shape of nested data:
fn get_name(user: Option[User]) -> String {
match user {
Some(User { name, .. }) => name,
None => "anonymous",
}
}
.. ignores the remaining fields you don't care about.
Guards
Add conditions with if:
fn classify_temp(temp: f64) -> String {
match temp {
t if t < 0.0 => "freezing",
t if t < 20.0 => "cold",
t if t < 30.0 => "comfortable",
_ => "hot",
}
}
The variable t binds the matched value, and the if clause adds an extra condition.
Exhaustiveness
The compiler requires that match covers every possible case. This is enforced at compile time:
enum Color {
Red,
Green,
Blue,
}
fn name(c: Color) -> String {
match c {
Color.Red => "red",
Color.Green => "green",
// compile error: non-exhaustive match — Color.Blue not covered
}
}
This is especially powerful with enums: if you add a new variant, the compiler tells you everywhere you need to handle it. No silent bugs from forgotten cases.
let patterns
You can destructure in let bindings too:
let Point { x, y } = get_point();
let (first, second) = get_pair();
if let
For when you only care about one variant:
if let Some(user) = find_user(42) {
println(f"Found: {user.name}");
}
This is cleaner than a full match when you'd just ignore the other cases.
Error Handling
Kāra has no exceptions. No try/catch. Errors are values, handled explicitly through Result and Option.
This sounds strict, but in practice it makes error handling easier — the compiler tracks which operations can fail and makes sure you handle them.
Result and the ? operator
An operation that can fail returns Result[T, E]:
fn parse_port(s: String) -> Result[u16, ParseError] {
// returns Ok(port) or Err(error)
}
The ? operator propagates errors to the caller:
fn load_config(path: String) -> Result[Config, Error] {
let text = read_file(path)?; // if Err, return it immediately
let config = parse_toml(text)?; // same here
Ok(config) // success
}
Without ?, you'd need a match at every step. ? keeps the happy path clean.
Option and ?
? also works with Option:
fn first_word(text: Option[String]) -> Option[String] {
let t = text?; // if None, return None
let words = t.split(' ');
words.first()
}
Optional chaining
For navigating nested optional values:
let city = user.address?.city?.name;
If any step is None, the whole expression short-circuits to None.
Default values with ??
let name = user.nickname ?? "anonymous";
let port = parse_port(input) ?? 8080;
?? provides a fallback when the left side is None or Err. The fallback is evaluated lazily — only when needed.
Adding context to errors
The ? operator records where an error traveled. .context() adds why:
fn process_user(id: u64) -> Result[Report, Contextual[AppError]] {
let user = db.find(id)
.context(f"while loading user {id}")?;
let orders = db.orders_for(user.id)
.context(f"while fetching orders for user {id}")?;
build_report(user, orders)
}
This builds a chain of context that helps debugging: you see both the original error and the high-level operation that failed.
unwrap — the escape hatch
unwrap() extracts the value from Option or Result, crashing if it's None or Err:
let value = some_option.unwrap(); // panics if None
This produces the panics effect — the compiler tracks it through your call chain. Public functions that can panic must declare it. For production code, prefer ?, ??, unwrap_or(default), or match.
unwrap() is useful in tests, prototypes, and cases where you've already validated the value can't be None/Err.
Cleanup with defer and errdefer
defer runs cleanup when a scope exits, regardless of how:
fn process_file(path: String) -> Result[Data, Error] {
let file = open(path)?;
defer file.close(); // always runs when scope exits
let data = parse(file)?; // if this fails, file still gets closed
Ok(data)
}
errdefer runs cleanup only on the error path:
fn open_connection(addr: String) -> Result[Connection, Error] {
let conn = Connection.open(addr)?;
errdefer conn.close(); // only if we return Err below
register_metrics(conn)?; // if this fails, close the connection
Ok(conn) // success: errdefer does NOT run
}
Multiple defer/errdefer blocks run in reverse order (last declared, first executed).
The error handling philosophy
Kāra's approach comes down to:
- Errors are data. They flow through the type system like any other value.
- The compiler enforces handling. You can't silently ignore a
Result. ?makes the happy path clean. Error propagation is one character, not five lines of boilerplate.deferhandles cleanup. No RAII gymnastics, no finally blocks — just "run this when we leave."
The result is code where the error paths are visible but not noisy.
Traits and Generics
Traits
A trait defines shared behavior — a set of methods that different types can implement:
trait Area {
fn area(ref self) -> f64;
}
Types implement traits with impl:
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Area for Circle {
fn area(ref self) -> f64 {
3.14159 * self.radius * self.radius
}
}
impl Area for Rectangle {
fn area(ref self) -> f64 {
self.width * self.height
}
}
Default methods
Traits can provide default implementations:
trait Describable {
fn name(ref self) -> String;
fn description(ref self) -> String {
f"A thing called {self.name()}"
}
}
Types that implement Describable must provide name, but get description for free. They can override it if they want.
Generics
Generics let you write code that works with any type. Kāra uses [T] syntax — not <T>:
fn first[T](items: Vec[T]) -> Option[T] {
items.get(0)
}
This works for Vec[i64], Vec[String], Vec[User] — anything.
Generic structs
struct Pair[A, B] {
first: A,
second: B,
}
let p = Pair { first: "hello", second: 42 };
Generic with trait bounds
Constrain what types are allowed:
fn largest[T: Ord](items: Vec[T]) -> T {
let mut best = items[0];
for item in items {
if item > best {
best = item;
}
}
best
}
T: Ord means "T must implement the Ord trait" — so we know > works. Multiple bounds use +:
fn print_sorted[T: Ord + Display](items: Vec[T]) {
let sorted = items.sort();
for item in sorted {
println(item);
}
}
Why [T] instead of <T>?
No ambiguity with comparison operators. Vec[i32] can't be misread as "is Vec less than i32." No turbofish needed. The tradeoff is that [ does double duty for generics and indexing, but the parser disambiguates by context:
- Type positions (annotations, return types):
Vec[i64]is always generic. - Expression positions:
arr[0]is always an index. A generic call is recognized by(after]:sort[i32](data).
Putting it together
Here's a generic function with a trait bound and a return type:
fn find[T: Eq](items: Vec[T], target: T) -> Option[u64] {
for i in 0..items.len() {
if items[i] == target {
return Some(i);
}
}
None
}
fn main() {
let names = ["Alice", "Bob", "Charlie"];
match find(names, "Bob") {
Some(i) => println(f"Found at index {i}"),
None => println("Not found"),
}
}
The compiler infers T = String from the arguments. No annotation needed at the call site.
Trait objects: dyn Trait
Generics specialize at compile time — one copy of the function per concrete T. Sometimes you want a single collection or parameter that can hold different types sharing a trait. That's dynamic dispatch, written dyn Trait:
let pets: Vec[dyn Animal] = [cat, dog];
fn render(shape: ref dyn Shape) -> String {
shape.describe()
}
The dyn keyword is required — writing Vec[Animal] is a compile error. Keeping the keyword visible means the choice between static and dynamic dispatch is legible at the type itself.
Owned dyn Trait (Vec[dyn Animal], Box[dyn Animal]) requires a heap allocation; ref dyn Trait borrows a value that already lives somewhere else and doesn't.
Collections
This chapter is a work in progress.
Sequence literals
The bare form [1, 2, 3] creates a Vec — growable, heap-allocated, pushed into, returned from functions:
let names = ["Alice", "Bob", "Charlie"]; // Vec[String]
let numbers = [1, 2, 3]; // Vec[i64]
When you want a different collection, write its name as a prefix:
let xs = Array[1, 2, 3]; // Array[i64, 3] — fixed-size, stack-allocated
let s = Set[1, 2, 3]; // Set[i64]
let m = Map["a": 1, "b": 2]; // Map[String, i64]
This form works anywhere a value is expected — function arguments, return values — where a binding's type annotation can't reach.
Vec
The growable array. The most common collection:
let mut numbers = Vec.new();
numbers.push(1);
numbers.push(2);
numbers.push(3);
// Or initialize with values:
let names = ["Alice", "Bob", "Charlie"];
for name in names {
println(name);
}
println(names[0]); // "Alice"
println(names.len()); // 3
Arrays
Fixed-size, stack-allocated. Size is part of the type — Array[i64, 4] and Array[i64, 5] are different types.
let xs = Array[10, 40, 20, 30]; // Array[i64, 4] — size and type inferred
let scores = Array[0; 4]; // Array[i64, 4] — four zeros via repeat form
// Or declare with an annotation:
let data: Array[i64, 4] = [10, 40, 20, 30];
let mut buf: Array[u8, 256] = [0; 256]; // annotation propagates u8 into elements
buf[0] = 100;
buf[1] = 85;
Map
Key-value pairs:
let mut ages = Map.new();
ages.insert("Alice", 30);
ages.insert("Bob", 25);
// Or initialize with values:
let scores = Map["Alice": 10, "Bob": 7];
match ages.get("Alice") {
Some(age) => println(f"Alice is {age}"),
None => println("Not found"),
}
Set
Unique values:
let mut seen = Set.new();
seen.insert("hello");
seen.insert("world");
seen.insert("hello"); // no effect, already present
// Or initialize with values:
let colors = Set["red", "green", "blue"];
println(seen.len()); // 2
Tuples
Fixed-size, mixed-type groups:
let pair = (42, "hello");
let (number, text) = pair;
fn min_max(items: Vec[i64]) -> (i64, i64) {
// return both at once
(items.min(), items.max())
}
Closures and Iterators
This chapter is a work in progress.
Closures
Anonymous functions that can capture variables from their environment:
let add = |a, b| a + b;
println(add(2, 3)); // 5
let threshold = 10;
let is_big = |n| n > threshold; // captures `threshold`
println(is_big(15)); // true
Closures as parameters
Functions can accept closures:
fn apply_twice(f: Fn(i64) -> i64, x: i64) -> i64 {
f(f(x))
}
let double = |n| n * 2;
println(apply_twice(double, 3)); // 12
Closures and effects
Closures inherit effects from the code they contain. A closure that calls println carries a writes(Stdout) effect. The effect system tracks this through higher-order functions — no surprise side effects hiding in callbacks.
Iterators
Iterators let you process sequences lazily:
let numbers = [1, 2, 3, 4, 5];
let doubled = numbers
.iter()
.map(|n| n * 2)
.filter(|n| n > 4)
.collect();
// doubled = [6, 8, 10]
Iterators work the same over effectful sources. io.lines(), fs.read_lines(path), and future channel/consumer adaptors return impl Iterator too — the source's effects (reads(Stdin), blocks, receives(Kafka), …) flow through map / filter / take into the enclosing function's effect row. One trait, one combinator library; no separate Stream type.
Common iterator methods
items.map(|x| transform(x)) // transform each element
items.filter(|x| predicate(x)) // keep elements that match
items.fold(init, |acc, x| ...) // reduce to a single value
items.any(|x| x > 10) // true if any element matches
items.all(|x| x > 0) // true if all elements match
items.enumerate() // pairs of (index, value)
items.zip(other) // pairs from two iterators
items.take(n) // first n elements
items.skip(n) // skip first n elements
The pipe operator with iterators
The pipe operator |> chains transformations naturally:
let result = data
|> parse
|> validate
|> transform;
The Effect System
This is the feature that defines Kāra. The effect system tracks what your code does to the outside world — and uses that knowledge to verify correctness, generate better diagnostics, and automatically parallelize work.
The idea
Every interaction with the outside world is an effect: reading a file, writing to a database, sending a network request, allocating memory. In most languages, these are invisible — a function might do anything and the caller has no way to know.
In Kāra, effects are tracked. The compiler knows which functions read from the filesystem, which write to a database, which send network requests. This information flows through the type system and enables powerful guarantees.
Effects = verbs + resources
An effect is a verb applied to a resource:
reads(FileSystem) — reads from the filesystem
writes(Database) — writes to a database
sends(Net) — sends data over the network
receives(Net) — receives data from the network
allocates(Heap) — allocates memory
panics — might crash (no resource needed)
The six built-in verbs are: reads, writes, sends, receives, allocates, panics.
Resources are user-defined. You declare what exists in your domain:
effect resource FileSystem;
effect resource Database;
effect resource Cache;
effect resource Net;
Private functions: effects are inferred
For internal functions, the compiler figures out the effects automatically:
fn load_data(path: String) -> String {
read_file(path) // compiler infers: reads(FileSystem)
}
fn save_report(data: String) {
write_file("report.txt", data) // compiler infers: writes(FileSystem)
println("Saved."); // compiler infers: writes(Stdout)
}
You write normal code. The compiler tracks what it does. No annotation needed.
Public functions: effects are declared
At API boundaries, you declare your effects explicitly. This is a contract with your callers:
pub fn fetch_user(id: u64) -> Result[User, Error]
with reads(Database) sends(Net)
{
let cached = check_cache(id);
match cached {
Some(user) => Ok(user),
None => load_from_api(id),
}
}
The with clause lists every effect the function may produce. The compiler verifies that the body doesn't exceed the declared effects — if you add a write_file call inside, the compiler will reject it because writes(FileSystem) isn't declared.
This is the key insight: effects are the primary interface of Kāra. They tell callers what a function does to the world. Ownership and layout are implementation details the compiler manages; effects are what you declare and what gets verified.
Why this matters
1. The compiler catches mistakes
If your function claims reads(Database) but you accidentally added a line that writes to it, the compiler tells you. You either fix the code or update the declaration.
2. Automatic parallelization
Two function calls with non-conflicting effects can run in parallel:
fn generate_report(id: u64) -> Report
with reads(UserDB) reads(OrderDB) reads(Analytics)
{
let user = fetch_user(id); // reads(UserDB)
let orders = fetch_orders(id); // reads(OrderDB)
let stats = fetch_analytics(id); // reads(Analytics)
build_report(user, orders, stats)
}
The three fetches read from different resources. The compiler can prove they don't interfere with each other and run them concurrently — without you writing any threading code. We'll cover this in Chapter 14.
3. Documentation that can't lie
The with clause is a machine-checked description of what a function does. It can't go stale like a comment. It can't be wrong like a docstring. If the declaration says reads(Database), the function reads from the database and does nothing else that isn't declared.
Effect groups
For common combinations, define groups:
effect group IO = reads(FileSystem) + writes(FileSystem) + reads(Env);
Then use the group in declarations:
pub fn process(path: String) -> Result[Data, Error] with IO {
// can read and write files, read environment variables
}
What's next
The effect system goes deeper — effect polymorphism, parameterized resources, providers, conflict detection. But the core idea is what matters: declare what your code does to the world, and the compiler verifies it. Everything else builds on that.
Ownership Without the Fight
If you've used Rust, you know the ownership system: powerful but demanding. Lifetimes, borrowing rules, the borrow checker rejecting code you know is safe.
Kāra keeps the semantics and drops the lifetimes. Borrows are declared at the signature with a word (ref, mut ref), and the compiler verifies the body matches. RC fills in when a single owner can't be proven.
The three tiers
Every value in Kāra lives in one of three ownership tiers:
- Owned — the default. The value has one owner. When the owner goes out of scope, the value is dropped.
- Ref (borrowed) — a temporary view of someone else's value.
ref Tis shared/read-only,mut ref Tis exclusive/mutable. Like Rust's&T/&mut T, without lifetime variables. - RC (reference-counted) — shared ownership. Multiple owners, reference-counted. The compiler adds this automatically when needed.
You write the tier at the signature. The compiler infers nothing about the public contract — what the source says is what callers see.
Parameter modes
Every parameter names its mode in the signature. Default (owned) is bare; borrows are written:
fn greet(name: ref String) {
println(f"Hello, {name}!");
}
fn take_name(name: String) -> String {
name // consumed — owned parameter, moves out
}
fn uppercase(name: mut ref String) {
name.make_uppercase(); // exclusive borrow — mutates in place
}
Three rules, one per mode. The body must match: consuming an owned parameter is fine; consuming a ref parameter is a compile error; writing through a mut ref is fine, consuming it is not.
Receivers
Methods follow the same rule. Bare self is the owned/consuming receiver, ref self is a shared borrow, mut ref self is an exclusive borrow:
impl Builder {
fn build(self) -> Widget { ... } // consumes self
fn peek(ref self) -> i64 { self.count } // reads self
fn bump(mut ref self) { self.count = self.count + 1 } // mutates self
}
No own self — the keyword own isn't written anywhere in a signature. Owned is always the bare form.
karac explain
If you write fn greet(name: String) and only read name in the body, the compiler accepts it — but karac explain reports the "would-be mode" for each parameter, so you can tighten signatures when performance matters. The report is diagnostic, not contractual: callers always see what you wrote.
Call sites
At call sites, mutation gets a marker when the argument is a fresh binding passed to a mut ref T or mut Slice[T] parameter:
let mut v = [3, 1, 4, 1, 5];
sort_in_place(mut v); // fresh binding → marker required
Inside a function that already holds the binding as a mut ref, you don't repeat the marker — the mutation was announced at the callee's signature:
fn helper(s: mut ref State) {
update(s.cache); // field through a mut-ref root → no marker
reset(s.counter); // same — forwarded
}
Method calls, field assignment, and index assignment never carry the marker:
v.push(x); // method call — silent
s.field = 5; // field assignment — silent
v[i] = x; // index assignment — silent
ref is never written at call sites — the signature carries the mode. f(ref v) is a parse error.
Move semantics
When a value is moved, the original binding is gone:
let a = Vec.new();
let b = a; // `a` is moved into `b`
// println(a); // compile error: `a` has been moved
println(b); // ok
This prevents use-after-move bugs at compile time. No dangling pointers, no double frees.
RC fallback
Sometimes the compiler can't prove a single-owner model works — the value is shared across data structures, or its lifetime can't be statically determined. In these cases, the compiler falls back to reference counting:
let node = Node { value: 42, children: Vec.new() };
// If `node` ends up shared across a graph structure,
// the compiler automatically wraps it in RC.
You don't write Rc[Node] or Arc[Node]. The source code always says Node. The compiler picks the representation, and karac explain tells you what it chose.
Slices
A slice is a borrowed view into contiguous memory — a pointer and a length, nothing more. Kāra has two:
StringSlice— a view into aString.Slice[T]— a view into any sequence ofT(usually aVec[T]orArray[T, N]).
Slices let one function work over many container types:
fn sum(xs: Slice[i64]) -> i64 {
let mut acc = 0;
for x in xs { acc = acc + x; }
acc
}
let v: Vec[i64] = [1, 2, 3, 4];
let a: Array[i64, 3] = [10, 20, 30];
sum(v); // Vec coerces to Slice at the call boundary
sum(a); // Array coerces too
sum(v[1..3]); // a sub-range is also a Slice
You don't write sum(v.as_slice()) — the compiler inserts the coercion when a call expects Slice[T] and the argument is a compatible owned or borrowed container. When you need a slice as a first-class value (stored in a let, captured by a closure), call .as_slice() explicitly.
Mutable slices
For in-place operations, use mut Slice[T] — the same mut modifier Kāra uses everywhere else:
fn sort_in_place[T: Ord](xs: mut Slice[T]) { /* ... */ }
let mut v = [3, 1, 4, 1, 5];
sort_in_place(mut v); // mutably borrow the whole Vec
sort_in_place(mut v[1..4]); // or just a sub-range
Why slices matter
Without slices, a function that operates on a sequence has to choose between being too restrictive (ref Vec[i64] — rejects arrays) and too generic (a trait bound — loses O(1) indexed access). Slices give you the middle ground: one signature that works over any contiguous sequence, with full random access.
StringSlice
StringSlice is the string counterpart to Slice[T] — a borrowed view into a String. The shape is a pointer, an offset, and a length.
fn first_word(s: ref String) -> StringSlice {
let end = s.find(' ').unwrap_or(s.len());
s.slice(0, end) // no allocation — points into s's buffer
}
let line: String = "hello world".to_string();
let head = first_word(ref line); // "hello", zero-copy view into `line`
The reason StringSlice exists alongside ref String is the same reason Slice[T] exists alongside ref Vec[T]: ref String can only point at a whole String, but most string operations want a sub-range. Splitting is the clearest case:
let csv: String = "alice,30,engineer".to_string();
let fields: Vec[StringSlice] = csv.split(',');
// three views into csv — "alice", "30", "engineer" — no bytes copied
There is no separate String for "alice" anywhere in memory. If split returned Vec[ref String], it would have to allocate a new String for each piece just so there was something for the refs to point at. StringSlice's offset field lets it name a range without needing a standalone String to exist.
When you need to keep a view beyond the borrow, call .to_string() to copy it into a new owned String:
let owned: String = fields[0].to_string();
Two notes for later: StringSlice is implicitly Copy, so passing it by value never invalidates the caller's binding. And a StringSlice is not Slice[u8] — the UTF-8 invariant is carried by the type itself, so byte indexing and character indexing stay distinct operations.
shared types
For types that are designed for shared ownership, use shared:
shared struct TreeNode {
value: i64,
left: Option[TreeNode],
right: Option[TreeNode],
}
shared struct means: this type always uses reference counting. It's the right tool for trees, graphs, and any structure where multiple parents point to the same child.
The philosophy
Kāra's ownership model: Rust semantics, no lifetimes, one word per mode.
- Signatures declare the mode with a word: bare for owned,
ref/mut reffor borrows. - Call sites mark mutation for fresh bindings with
mut; forwarded mut-refs and method calls stay silent. - The compiler never silently copies expensive data. Moves are explicit in the semantics.
- When you need to see what the compiler chose (RC flavor, representation),
karac explainshows you. - Lifetimes never appear in source. The compiler infers borrow scoping below the signature surface.
The goal is Rust-level safety with mainstream-language readability — no <'a>, no turbofish, one unified rule for borrows across free functions, methods, and traits.
Modules and Visibility
File = module
In Kāra, every .kara file is a module. The directory structure defines the module tree — no mod declarations needed:
src/
main.kara // entry point
db/
connection.kara // module: db.connection
pool.kara // module: db.pool
auth/
token.kara // module: auth.token
The compiler discovers all .kara files automatically. No manifest of modules to maintain.
Module names are Value-class identifiers — always snake_case. This falls out of the identifier case-class rules introduced in chapter 2; db, connection, auth_token are valid, Db or AuthToken as module names are compile errors.
Three levels of visibility
| Keyword | Who can see it |
|---|---|
pub | Everyone, including users of your library |
| (default) | All files in your project |
private | Files in the same directory only |
pub fn validate(input: String) -> bool { ... } // public API
fn helper(s: String) -> String { ... } // project-internal
private fn secret_impl() { ... } // same directory only
Why default is project-internal
This will surprise you if you're coming from Rust or Java, where default = private to the module.
In Kāra, modules are directories. If the default were "private to this directory," you'd need pub on almost every cross-directory call within your own project. The current default covers the common case: internal code that your own files need to call.
You only annotate the boundaries:
pubfor things external users should see.privatefor helpers that shouldn't leak outside their directory.
Imports
import db.connection.Connection;
import auth.token.Token;
// Multiple items from the same module
import std.collections.{Map, Set};
// Rename an imported item
import std.collections.Map as Dict;
Import paths are absolute from the crate root. Every file writes the same path for the same item, regardless of where it sits in the directory tree.
Re-exports
Libraries can present a clean public surface:
// lib.kara
pub import db.connection.Connection;
pub import db.pool.Pool;
pub import auth.token.Token;
// Users write:
import mylib.Connection; // not mylib.db.connection.Connection
Reorganize your internals without breaking users.
The prelude
These are available everywhere without imports:
- Types:
Option,Result,Vec,String,StringSlice,Map,Set, and all primitives. - Variants:
Some,None,Ok,Err. - Functions:
print,println,eprintln. - Builtins:
todo,unreachable,dbg,assert,assert_eq.
Project layout
myproject/
kara.toml // project manifest (like Cargo.toml)
src/
main.kara // executable entry point
lib.kara // library entry point (instead of main.kara)
tests/
db_test.kara // integration tests
examples/
basic.kara // runnable examples
Dependencies go in kara.toml:
[package]
name = "myproject"
version = "0.1.0"
[dependencies]
http = "1.2"
json = { version = "0.8", git = "https://github.com/example/json-kara" }
Concurrency
This chapter is a work in progress.
Kāra's concurrency story is built on a simple idea: if the compiler can prove two operations don't interfere, it can run them in parallel. The effect system makes this possible.
Automatic parallelization
Consider a function that fetches data from three independent sources:
fn build_dashboard(user_id: u64) -> Dashboard
with reads(UserDB) reads(OrderDB) reads(Analytics)
{
let profile = fetch_profile(user_id); // reads(UserDB)
let orders = fetch_orders(user_id); // reads(OrderDB)
let stats = fetch_analytics(user_id); // reads(Analytics)
Dashboard.new(profile, orders, stats)
}
The three fetches operate on different resources. The compiler proves they don't conflict and runs them concurrently — zero threading code from you.
This is possible because of the effect system. Without knowing which resources each call touches, the compiler couldn't prove independence. Effects are what make auto-concurrency safe.
Explicit concurrency with par
When you want to be explicit about parallelism:
let (users, products) = par {
fetch_users(),
fetch_products(),
};
par runs its branches concurrently and waits for all to complete. It's structured concurrency — no dangling tasks, no fire-and-forget.
spawn for background work
let handle = spawn(long_computation());
// ... do other work ...
let result = handle.await;
Parallel failure
When one branch of a par block fails:
- Sibling branches are cancelled cooperatively.
- Each branch's cleanup (
defer/errdefer) runs. - The first error is returned.
No orphaned tasks. No silent failures. Structured concurrency means the scope waits for everything to finish before proceeding.
The runtime
Kāra's concurrency runtime uses work-stealing with a thread pool. The details are an implementation choice — your code doesn't depend on them. You write sequential-looking code with effect annotations; the compiler and runtime handle the rest.
Data Layout
This chapter is a work in progress.
Most languages give you no control over how data is arranged in memory. Kāra lets you separate what your data is from how it's stored — without changing the logical API.
Why layout matters
Modern CPUs are memory-bound, not compute-bound. Cache misses dominate performance. How your data is laid out in memory — whether related fields are next to each other, whether you're iterating over dense arrays or chasing pointers — matters more than most algorithmic optimizations.
Layout blocks
A layout block defines physical memory organization separately from the struct definition:
struct Particle {
position: Vec3,
velocity: Vec3,
mass: f64,
color: Color,
active: bool,
}
layout Particle {
group hot { position, velocity } // fields accessed together in physics loop
group cold { color, active } // rarely accessed during simulation
}
The struct's logical API doesn't change — you still write p.position and p.color. But the compiler lays out hot fields contiguously for cache-friendly iteration and keeps cold fields separate.
SoA transforms
For arrays of structs, layout blocks can request Structure-of-Arrays (SoA) layout:
layout Particle {
soa // each field becomes its own contiguous array
}
An Array[Particle, 1000] with soa layout stores all positions together, all velocities together, etc. — ideal for SIMD and cache performance. The logical interface (particles[i].position) stays the same.
When to use layout control
Most code doesn't need layout blocks. Use them when:
- You have hot loops iterating over large arrays of structs.
- Profiling shows cache misses dominating.
- You want SoA layout for SIMD-friendly processing.
For everything else, let the compiler pick the layout. It's implementation freedom — the compiler can optimize within the constraints you give it.
Testing
This chapter is a work in progress.
Kāra has built-in testing support — no external framework needed.
Unit tests
Test functions live alongside the code they test, in _test.kara files. Any function whose name begins with test_ is automatically discovered and run by karac test — no attribute or registration required:
// math.kara
fn add(a: i64, b: i64) -> i64 {
a + b
}
// math_test.kara
fn test_addition_works() {
assert_eq(add(2, 3), 5);
assert_eq(add(-1, 1), 0);
}
fn test_addition_is_commutative() {
assert_eq(add(3, 7), add(7, 3));
}
Assertions
Available everywhere as builtins:
assert(condition); // panics if false
assert_eq(left, right); // panics if not equal, shows both values
Property-based testing
Test with randomly generated inputs by adding #[property] to a test_* function with parameters:
#[property]
fn test_sort_is_idempotent(items: Vec[i64]) {
let sorted = items.sort();
assert_eq(sorted.sort(), sorted);
}
The test runner generates many random Vec[i64] values via the Arbitrary trait and checks the property holds for each. When a failure is found, it shrinks the input to the minimal reproducing case.
Snapshot testing
Capture output and compare against a saved baseline by adding #[snapshot] to a test_* function:
#[snapshot]
fn test_report_format() {
let report = generate_report(sample_data());
assert_snapshot(report);
}
On first run, the snapshot is saved. On subsequent runs, the output is compared. If it changed, the test fails and shows the diff. Accept new output as the baseline with karac test --update-snapshots.
Running tests
karac test # run all tests
karac test math # run tests whose fully-qualified ID contains "math"
karac test addition # run tests matching a substring of the test ID
Appendix A: Keywords
The following words are reserved by the Kāra language. You cannot use them as identifiers.
Declaration keywords
| Keyword | Purpose |
|---|---|
fn | Declare a function |
struct | Declare a struct |
enum | Declare an enum |
trait | Declare a trait |
impl | Implement a trait or add methods to a type |
type | Declare a type alias |
distinct | Declare a distinct (newtype) alias |
const | Declare a compile-time constant |
mod | Declare a module |
use | Bring a name into scope |
import | Import an external package |
extern | Declare a foreign function or type |
shared | Mark a struct or enum as reference-semantics (RC) |
layout | Declare a physical memory layout for a struct |
group | Group fields within a layout block |
effect | Declare an effect system definition |
resource | Declare an effect resource |
verb | Declare an effect verb |
alias | Declare that two resource names refer to the same underlying resource |
Visibility keywords
| Keyword | Purpose |
|---|---|
pub | Public — visible to external consumers |
private | Private — visible only within the current directory |
(Default visibility — no keyword — is project-internal: visible to all files in the project.)
Control flow keywords
| Keyword | Purpose |
|---|---|
if | Conditional branch |
else | Fallthrough branch for if |
match | Pattern-matching switch |
while | Condition-driven loop |
for | Iterator-driven loop |
in | Separator between pattern and iterable in for |
loop | Infinite loop |
return | Early return from a function |
break | Exit from a loop |
continue | Skip to the next loop iteration |
defer | Run a block when the enclosing scope exits (success path) |
errdefer | Run a block when the enclosing scope exits via ?-propagated error |
asm | Inline assembly block |
global_asm | Module-level assembly block |
Binding keywords
| Keyword | Purpose |
|---|---|
let | Declare a local binding |
mut | Mark a binding or parameter as mutable |
Ownership and borrowing keywords
| Keyword | Purpose |
|---|---|
own | Explicit owned parameter mode (rarely needed — owned is the default) |
ref | Borrow a value (read-only reference) |
weak | Weak reference into an RC type |
lock | Lock resource |
Effect keywords
| Keyword | Purpose |
|---|---|
reads | Effect: reads from a resource |
writes | Effect: writes to a resource |
sends | Effect: sends to a resource |
receives | Effect: receives from a resource |
allocates | Effect: allocates from a resource |
panics | Effect: may panic |
blocks | Effect: may block the calling thread |
suspends | Effect: may yield to the scheduler |
with | Introduce an effect annotation or effect variable |
transparent | Mark an effect as transparent (not attributed to callers) |
stable | Mark an effect annotation as part of the public API contract |
seq | Sequential block |
par | Parallel block (branches may execute concurrently) |
yield | Yield a value from a generator |
Type system keywords
| Keyword | Purpose |
|---|---|
as | Type cast or trait disambiguation |
where | Introduce generic bounds or refinement-type predicates |
dyn | Dynamic dispatch through a trait object |
Self | The type of the current impl block or trait |
self | The receiver value within a method |
Contract keywords
| Keyword | Purpose |
|---|---|
requires | Precondition contract on a function |
ensures | Postcondition contract on a function |
invariant | Invariant check at the end of every method in an impl block |
Safety keywords
| Keyword | Purpose |
|---|---|
unsafe | Mark a block or function as bypassing safety checks |
Concurrency and context keywords
| Keyword | Purpose |
|---|---|
providers | Introduce a provider scope |
independent | Declare that two resources are independent for conflict analysis |
Literal keywords
| Keyword | Purpose |
|---|---|
true | Boolean true |
false | Boolean false |
Reserved for future use
These words are reserved now; using them as identifiers is a compile error.
| Keyword | Planned use |
|---|---|
f16 | Half-precision float (Phase 7+) |
bf16 | Brain-float (Phase 7+) |
Primitive type names
These are lexer-level keywords, not identifiers. They are always in scope and require no import.
i8, i16, i32, i64, u8, u16, u32, u64, f32, f64, bool, char, ! (the never type)
Appendix B: Operators and Symbols
Arithmetic operators
| Operator | Meaning | Trait |
|---|---|---|
a + b | Add | Add |
a - b | Subtract | Sub |
a * b | Multiply | Mul |
a / b | Divide | Div |
a % b | Remainder | Rem |
All arithmetic operators lower to trait-method calls after type checking. Writing a + b where A does not implement Add is a type error that names the missing trait, not the operator.
Integer arithmetic uses trap-on-overflow semantics in app and lib profiles. Use named methods for explicit control:
| Method | Behavior |
|---|---|
a.checked_add(b) → Option[T] | Returns None on overflow |
a.saturating_add(b) | Clamps to T::MAX / T::MIN |
a.wrapping_add(b) | Two's-complement wraparound |
a.overflowing_add(b) → (T, bool) | Wraparound + overflow flag |
Bitwise operators
| Operator | Meaning | Trait |
|---|---|---|
a & b | Bitwise AND | BitAnd |
a | b | Bitwise OR | BitOr |
a ^ b | Bitwise XOR | BitXor |
a << b | Left shift | Shl |
a >> b | Right shift | Shr |
Comparison operators
| Operator | Meaning | Trait |
|---|---|---|
a == b | Equal | PartialEq |
a != b | Not equal | PartialEq |
a < b | Less than | PartialOrd |
a <= b | Less than or equal | PartialOrd |
a > b | Greater than | PartialOrd |
a >= b | Greater than or equal | PartialOrd |
Logical operators
| Operator | Meaning |
|---|---|
a && b | Short-circuit logical AND |
a || b | Short-circuit logical OR |
!a | Logical NOT |
Assignment operators
| Operator | Meaning |
|---|---|
a = b | Assign |
a += b | Add-assign |
a -= b | Subtract-assign |
a *= b | Multiply-assign |
a /= b | Divide-assign |
a %= b | Remainder-assign |
a &= b | Bitwise-AND-assign |
a |= b | Bitwise-OR-assign |
a ^= b | Bitwise-XOR-assign |
a <<= b | Left-shift-assign |
a >>= b | Right-shift-assign |
Range operators
| Operator | Meaning | Example |
|---|---|---|
a..b | Half-open range [a, b) | 0..10 |
a..=b | Closed range [a, b] | 1..=5 |
a.. | Range from a to end | slice[2..] |
..b | Range from start to b (exclusive) | slice[..4] |
.. | Full range | slice[..] |
Other operators and symbols
| Symbol | Meaning |
|---|---|
? | Propagate an Err result early (error shorthand) |
a |> f | Pipe a as the first argument to f |
a ?? b | Nil-coalesce: return a if it is Some, else b |
a?.b | Optional chaining: access .b only if a is Some |
a as T | Cast a to type T |
* | Prefix dereference (planned — *r where r: ref T yields T) |
_ | Wildcard pattern or unnamed placeholder |
.. | Struct-update spread in struct literals |
-> | Return-type annotation in function signatures |
=> | Pattern arm separator in match |
:: | Path separator in qualified names |
@ | Attribute prefix |
#[...] | Attribute on a declaration |
Numeric literal suffixes
Force the type of a literal where inference cannot propagate from a binding annotation.
| Suffix | Type |
|---|---|
42i8 | i8 |
42i16 | i16 |
42i32 | i32 |
42i64 | i64 (default for integer literals) |
42u8 | u8 |
42u16 | u16 |
42u32 | u32 |
42u64 | u64 |
1.0f32 | f32 |
1.0f64 | f64 (default for float literals) |
Unsuffixed integer literals default to i64; unsuffixed float literals default to f64. In a binary expression, an unsuffixed literal may be promoted to the type of its suffixed sibling.
String literal prefixes
| Prefix | Meaning |
|---|---|
"..." | Plain string literal |
f"..." | Interpolated string — {expr} inserts the Display value of expr |
f"...{expr:?}..." | Interpolated with Debug formatting |
b"..." | Byte string (future) |
r"..." | Raw string — no escape processing (reserved, not yet implemented) |
Appendix C: Derivable Traits
#[derive] is a compiler built-in that generates trait implementations mechanically from a type's fields or variants. You list the traits you want derived in one attribute:
#[derive(PartialEq, Eq, Hash, Display)]
struct Point {
x: f64,
y: f64,
}
The compiler resolves derive dependencies automatically regardless of the order you list them. Writing #[derive(Hash)] when PartialEq and Eq are not yet derived causes the compiler to derive them first, in the correct order.
Equality and ordering
PartialEq
Generates == and != by comparing fields pairwise in declaration order. For enums, first checks that the variants match, then compares fields.
No dependencies.
Eq
A marker trait indicating that == is a total equivalence relation (reflexive, symmetric, transitive, and always defined). Adds no method body — it is a promise to the type system.
Requires: PartialEq
PartialOrd
Generates <, <=, >, >= with lexicographic field-order comparison. Returns Option[Ordering] because NaN != NaN for floats; for types without NaN, every comparison returns Some(...).
Requires: PartialEq
Ord
Total ordering: every pair of values is comparable. Generates a complete lexicographic comparison in declaration order.
Requires: PartialOrd + Eq
Hashing
Hash
Generates a hash method that feeds each field into a Hasher. Used by Map and Set as key types.
Requires: Eq (reflects the consistency contract: a == b must imply hash(a) == hash(b))
Display and debugging
Display
Generates a human-readable string representation.
- Structs: emits
TypeName { field: value, ... }forpubfields. Use#[derive(Display(all_fields))]to include private fields. - Enums: emits the variant name. Use
#[derive(Display(snake_case))]to emit insnake_caseinstead of the declaredPascalCase. For variants with data, appends the fields in parentheses.
Use Display for values shown to end users. Implement it manually to override the generated representation.
No dependencies.
Debug
Generates a developer-oriented representation. Used by {expr:?} in interpolated strings and by the test runner when printing unexpected values.
- Structs: always includes all fields, regardless of visibility.
- Enums: includes variant name and all fields.
No dependencies.
Default values
Default
Generates a T.default() method that returns a "zero-like" value for the type. The derived implementation calls .default() on each field in declaration order and constructs the struct. For enums, the first declared variant is used, with each of its fields defaulted.
#[derive(Default)]
struct Config {
timeout_ms: i64, // defaults to 0
retries: i64, // defaults to 0
verbose: bool, // defaults to false
}
let cfg = Config.default();
Requires: every field must also implement Default.
Copying
Clone
Generates a .clone() method that produces a deep copy of the value. For reference-semantics (shared) types, cloning produces a new RC handle, not a new heap allocation.
No dependencies.
Copy
Marks a type as trivially copyable (bitwise copy semantics). Assignment and passing to functions copy the value silently instead of moving it. All primitive types are Copy.
Requires: every field of the type must also be Copy.
Auto-derives Clone: #[derive(Copy)] automatically adds Clone if not already present.
Arithmetic on distinct types
Arithmetic
Available on distinct (newtype) types only. Generates +, -, *, /, % by forwarding to the underlying type's operations and wrapping the result back in the newtype. Without this derive, arithmetic between two values of the same distinct type is a type error (intentional: distinct types are supposed to be incompatible units).
distinct type Metres = f64;
#[derive(Arithmetic)]
struct Metres; // now Metres + Metres → Metres
Only valid on distinct types.
Serialization (post-v1)
Serialize
Generates a serialize method that visits each field in declaration order via a Serializer. Format backends (Json, Toml, MessagePack, etc.) implement Serializer — the derived code is format-agnostic.
Deserialize
Generates a deserialize static method that reconstructs the type field-by-field from a Deserializer.
Field-level attributes control serialization behavior:
#[derive(Serialize, Deserialize)]
struct Config {
host: String,
#[serde(rename = "port_number")]
port: u16,
#[serde(skip)]
internal_flag: bool,
}
Supported field attributes: rename, skip, skip_serializing, skip_deserializing, default.
Note: Serialize and Deserialize are post-v1. The derive syntax and field attributes are reserved now.
Dependency summary
| Trait | Auto-derives | Requires |
|---|---|---|
PartialEq | — | — |
Eq | — | PartialEq |
PartialOrd | — | PartialEq |
Ord | — | PartialOrd + Eq |
Hash | — | Eq |
Display | — | — |
Debug | — | — |
Clone | — | — |
Copy | Clone | every field is Copy |
Default | — | every field is Default |
Arithmetic | — | type must be distinct |
Serialize | — | — |
Deserialize | — | — |
Appendix D: Attributes
Attributes are metadata attached to declarations. Two syntactic forms are supported:
#[attribute_name] // marker
#[attribute_name(arg, ...)] // with arguments
@attribute_name // shorthand marker (selected attributes only)
Attributes appear immediately before the item they annotate.
Derive
#[derive(Trait, ...)]
Generates trait implementations for the annotated struct or enum. See Appendix C for the full list of derivable traits and their dependencies.
#[derive(PartialEq, Eq, Hash, Display, Clone)]
struct UserId { value: u64 }
Lint control
#[allow(lint_name)]
Suppress a specific lint within the annotated item. The lint fires nowhere inside the item.
#[warn(lint_name)]
Ensure a lint is at warning level even if it would otherwise be suppressed.
#[deny(lint_name)]
Promote a lint to a hard error within the annotated item.
Available lint names:
| Lint name | Default | What it checks |
|---|---|---|
undocumented_unsafe | warning | Every unsafe { } block must be preceded by a // Safety: comment |
ffi_float_eq / ffi_float_eq | warning | Comparing an extern "C" float return with == or != |
redundant_suffix | warning | Literal suffix that matches the default type (e.g., 42i64) |
mutual_recursion_note | note | Note when the SCC pass detects a mutual-recursion group |
module_mut_binding | warning (lib profile) | let mut at module scope |
layout_unassigned_fields | warning | Fields not assigned to a group in a layout block |
repr_c_layout_ignored | warning | layout block on a private struct (has no FFI effect) |
float_in_serialized_type | warning | f32/f64 field in a #[derive(Serialize, Deserialize)] type |
rc_fallback | note | Compiler chose RC tier to satisfy ownership analysis |
Safety
#[noblock] / @noblock
On an extern "C" or extern "C-unwind" function: removes blocks from the default effect set. Use this for pure-CPU foreign functions (math routines, strlen, etc.) that are known not to block.
@noblock
extern "C" fn sqrt(x: f64) -> f64;
Linker control
#[unsafe(no_mangle)]
Use the Kāra identifier as the exported symbol name without any name mangling. Required when a foreign caller (C, linker script, debugger) must reference the symbol by its exact Kāra name. Does not imply extern "C" — the calling convention is independent.
The #[unsafe(...)] wrap is mandatory: disabling name mangling can collide with foreign symbols, an obligation the compiler cannot verify. Bare #[no_mangle] is rejected at parse time.
#[unsafe(no_mangle)]
pub fn kara_entry() { ... }
#[used]
Prevent dead-code elimination for the annotated symbol even if no Kāra code references it. Use for linker-section entries, interrupt vectors, or other symbols that are referenced only from outside the compiler's visibility (linker scripts, hardware, debuggers). Stays plain (no #[unsafe(...)] wrap) — #[used] only suppresses DCE, no soundness obligation.
#[unsafe(link_section(".vectors"))]
#[used]
let interrupt_table: [fn(); 16] = [...];
#[unsafe(link_section("name"))]
Place the annotated symbol in a named linker section. Required for embedded targets that map specific sections to specific memory regions (flash, DTCM RAM, etc.).
The #[unsafe(...)] wrap is mandatory: section placement carries layout and aliasing obligations the compiler cannot verify. Bare #[link_section(...)] is rejected at parse time.
#[unsafe(link_section(".dtcmram"))]
let fast_buffer: [u8; 1024] = [0; 1024];
FFI
#[kara_name = "identifier"]
On an extern item: rebinds a non-conforming foreign name to a valid Kāra identifier. The Kāra-visible name must follow the identifier case-class rules; the foreign name may be arbitrary ASCII.
#[kara_name = "GlxFbConfig"]
extern type GLXFBConfig;
Embedded targets
#[interrupt]
Mark a function as an interrupt service routine (ISR) entry point. The compiler emits extern "interrupt" ABI, sets up the ISR stack frame, and places the handler in the .vectors linker section. Valid on embedded profile builds only.
#[interrupt]
fn TIM2() {
// handle timer interrupt
}
ISRs may not call panic!, allocate heap memory, or block. The effect checker enforces this at compile time. For an ISR that writes to a shared resource, wrap the resource in Atomic[T] or use a lock-free flag.
#[max_stack(N)] (embedded profile)
Assert that the annotated function's maximum stack depth (including all transitive callees) does not exceed N bytes. The compiler verifies this statically for embedded profile builds and emits an error if the bound cannot be guaranteed. Useful for ISR handlers, which run on a fixed-size interrupt stack.
#[interrupt]
#[max_stack(512)]
fn CAN1_RX0() { ... }
Module-level bindings
#[thread_local]
On a module-level let mut binding: gives each OS thread (and each task under the runtime) its own independent copy. The binding's initializer must still be a compile-time constant.
#[thread_local]
let mut request_count: i64 = 0;
Memory layout
#[repr(C)]
On a struct: lay out fields in C ABI order (declaration order, with C padding rules). Required for types passed through extern "C" boundaries.
#[repr(packed)]
On a struct: remove all padding. Fields may be unaligned — use unsafe for pointer access to packed fields.
#[repr(align(N))]
On a struct or as a wrapper type: require at least N-byte alignment.
Functions
#[must_use] / #[must_use = "reason"]
On a type: every binding site where a value of this type would be silently dropped produces a warning. Use for types that must be explicitly handled (e.g., a connection that must be closed).
On a function: the return value must not be silently discarded. Result return values are implicitly #[must_use].
#[must_use = "connections must be explicitly disconnected"]
struct Connection { ... }
GPU compute
#[gpu]
Declare that a function uses only the GPU-safe subset of Kāra and may be called from a GPU kernel. The compiler validates the full call graph from each #[gpu] root: forbidden effects (panics, allocates(Heap), I/O) are caught by the effect checker; forbidden structural features (heap types, recursion, dynamic dispatch, host-capturing closures) are caught during type checking. Dispatch to the GPU is always explicit via gpu.dispatch — #[gpu] is a constraint declaration, not a routing instruction.
#[gpu]
fn dot_product(a: Slice[f32], b: Slice[f32]) -> f32 { ... }
A generic function must be explicitly annotated with #[gpu] to be callable from a GPU kernel — GPU-callability is never inferred from the concrete type parameters.
Shared types and RC
#[cyclic]
On a trait: declare that the trait participates in ownership cycles. Any shared struct that holds a field of type dyn Trait (or a container of dyn Trait) for a #[cyclic] trait must use weak on that field. Without #[cyclic], dyn Trait fields in shared struct are allowed without weak. In debug builds, a leak detector catches missed cycle annotations at program exit (compiled out in release).
#[cyclic]
trait Node {
fn children(ref self) -> Slice[dyn Node];
fn parent(ref self) -> weak dyn Node;
}
Testing
#[test]
Mark a test_-prefixed function as a test case.
#[test(requires = [resource, ...])]
Mark a test that needs a live external resource. When the resource is unavailable, the test is skipped (or fails with reason: "unsatisfied_requires" when karac test --all is used).
#[property]
Mark a test_-prefixed function as a property test. The framework generates random inputs via Arbitrary and runs the body for each, shrinking on failure.
#[snapshot]
Mark a test_-prefixed function as a snapshot test. First run saves output; subsequent runs compare against the saved snapshot.
#[with_provider(resource_path, constructor_fn)]
Supply an in-memory provider for a test. The provider scope wraps the entire test body. Multiple #[with_provider] attributes are allowed; source order is outer-to-inner.
Tool-namespaced attributes
Multi-segment attribute paths of the form #[TOOL::NAME(...)] are reserved for external tools — formatters, linters, doc generators, IDE plugins, custom analyzers. The compiler accepts them syntactically, stores them on the AST, and otherwise ignores them; semantic interpretation is each tool's responsibility. The full design lives at design.md § Tool-Namespaced Attributes; this appendix entry catalogs the v1-reserved names and the read surface.
#[karafmt::skip]
fn manually_aligned_table() { 0 }
#[karalint::allow(complexity)]
fn complicated_inner_loop(data: ref Slice[Frame]) -> Frame {
// ...
}
#[acmecorp_security::audit_required(level: "strict")]
pub fn login(username: String, password: String) -> Result[Session, AuthError] { /* ... */ }
The discriminator is structural: a bare-name path (#[derive], #[no_mangle]) must match a known compiler attribute or it is error[E_UNKNOWN_ATTRIBUTE]; a multi-segment path is either a compiler-reserved namespace (#[diagnostic::*] — validated per Appendix D § Diagnostic) or a tool namespace (silently accepted). There is no per-project tool registration at v1; the open-namespace rule applies.
v1-reserved first-party tool namespaces
The Kāra organisation reserves three tool namespaces at v1 for the canonical first-party tools that will ship post-v1. User code may write attributes against them today — they parse and store like any other tool namespace — but their semantics are defined when the corresponding tool ships, and the names will not be reused by any other tool. The reservation is a name-claim, not an implementation commitment.
#[karafmt::*] (post-v1, reserved)
The canonical formatter. Initial members:
karafmt::skip— on any item: suppresses formatting for that item.
Until karafmt ships, #[karafmt::skip] is functionally a no-op.
#[karalint::*] (post-v1, reserved)
The canonical lint pack ride-along — separate from the compiler-built-in lints from Appendix D § Lint control. Initial members:
karalint::allow(NAME)/karalint::warn(NAME)/karalint::deny(NAME)/karalint::expect(NAME)— same shape as the compiler's built-in lint attributes but scoped to lints that live in the externalkaralintpackage.
#[karadoc::*] (post-v1, reserved)
The canonical doc generator. Initial members:
karadoc::hidden— on any item: omits the item from generated docs.
Third-party tool namespaces
Any other multi-segment path is also accepted. By convention, third-party tools use a namespace matching their package or organisation name (e.g., acmecorp_security::audit_required, mytool::config(level: 9)) to avoid collision with the reserved names above. The compiler does not enforce this convention; conflict-resolution authority is social — first registered, first served, with the v1-reserved names taking absolute precedence.
Reading tool attributes from outside the compiler
Tools consume tool-namespaced attributes via one of three paths:
karac query attributes [--tool=PREFIX]— emits a JSON list of every multi-segment attribute on every item, optionally filtered by first-segment prefix.--tool=karafmtreturns every#[karafmt::*]. Without--tool, returns every multi-segment attribute (including#[diagnostic::*]).- Language Server Protocol (post-v1) — the IDE-facing surface exposes the same data through workspace-symbol and document-symbol responses.
- Direct AST access — tools written in Kāra and using the compiler-as-library API read the same
Attribute { path, args, span }structures the typechecker stores.
Post-v1 / reserved
#[generational_fallback] (post-v1)
On a struct: opt into generational reference semantics for values of this type instead of RC. A generational handle holds an index into a Pool[T]; the pool validates liveness before each access. When a value outlives all its borrows, the pool slot is reclaimed and the generation counter is incremented, making stale handles detectable. This is a future opt-in for graph workloads where RC overhead is measurable — Pool[T] with explicit handles is the v1 alternative.
Serialization (post-v1)
#[serde(rename = "name")]
On a field in a #[derive(Serialize, Deserialize)] type: use "name" as the serialized key instead of the field name.
#[serde(skip)]
On a field: skip during both serialization and deserialization.
#[serde(skip_serializing)] / #[serde(skip_deserializing)]
On a field: skip during one direction only.
#[serde(default)]
On a field: use the field's Default value when the key is absent during deserialization.
#[serde(tag = "type")] / #[serde(untagged)]
On an enum: use internally-tagged or untagged representation instead of the default externally-tagged form.