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.
The same program, on disk
Save this to search.kara:
fn binary_search(haystack: ref Vec[i32], needle: i32) -> Option[i64] {
let mut lo: i64 = 0;
let mut hi: i64 = 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[i32] = [1, 3, 5, 7, 9, 11, 13];
match binary_search(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[i64]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 = [1, 3, 5, 7, 9, 11, 13];
karac> binary_search(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(nums, 100)
None
karac> binary_search(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 = [10, 20, 30, 40, 50];
karac> binary_search(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 = [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 {
... read_file("config.toml").unwrap()
... }
karac> :effects
session effects: reads(FileSystem), panics
read_config: reads(FileSystem), 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
A bare integer literal defaults to i64 and a bare float to f64, but a
literal coerces to fit its context — the annotated type of a binding, a
function parameter, or a struct field — as long as the value fits:
let n = 0; // i64 by default
let b: u8 = 200; // literal coerces to u8
fn takes_byte(x: u8) { ... }
takes_byte(255); // literal coerces to u8 at the call
When a literal has no context to coerce toward — or you want to override the default — pin its type with a suffix:
let mask = 0xFFu8; // u8, not i64
let ratio = 1.0f32; // f32, not f64
let zero = 0i64; // explicit i64
You'll see the suffix form (0i64, 1u8) throughout numeric code. Most of the
time the default or a binding annotation is enough; reach for a suffix when
inference has nothing to anchor to.
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 implicitly convert values of one numeric type to another — only
literals coerce to context (above). To convert a typed value, use as:
let x: i64 = 1000;
let y: i32 = x as i32; // widening — always safe
as covers every numeric pair, with semantics worth knowing:
let small: u8 = 7;
let wide = small as i64; // widening — value preserved (7)
let big: i64 = 300;
let trunc = big as u8; // narrowing — wraps modulo 2^8 (300 -> 44)
let avg = total as f64 / count as f64; // int -> float, before dividing
let k = 3.9 as i64; // float -> int — truncates toward zero (3)
Narrowing can change the value (it keeps the low bits), so cast down only when
you know it fits. Going int -> float -> int is the usual way to do real
division: cast to f64 first, or integer division truncates the result.
One conversion as won't do is u8 -> char — not every integer is a valid
Unicode scalar. Use char.try_from, which returns a Result; see
Strings and Bytes.
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 char_count(text: String) -> i64 {
text.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; char_count borrows the string.
You can also be explicit:
fn char_count(text: ref String) -> i64 {
text.len()
}
Both versions behave identically. The inference just saves you the annotation. We'll cover ownership in depth in Chapter 12.
To take a sequence without copying it — and without locking the caller into
one container type — declare the parameter as Slice[T]. A Vec[T] or an
Array[T, N] both coerce to a slice at the call, so one signature serves every
caller:
fn sum(xs: Slice[i64]) -> i64 {
let mut acc = 0i64;
for x in xs { acc = acc + x; }
acc
}
let v: Vec[i64] = [1, 2, 3];
let a: Array[i64, 2] = [10, 20];
sum(v); // Vec coerces
sum(a); // Array coerces too
This is the standard shape for a function that reads a list of values. See Slices for sub-ranges, mutable slices, and the full story.
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()}");
}
Newtype wrappers
For a lightweight wrapper around a single existing type, use a distinct type:
distinct type Meters = f64;
distinct type Seconds = f64;
These are distinct types — you can't accidentally pass Meters where Seconds is expected, even though both wrap f64. (See Appendix C for #[derive(Arithmetic)], which restores +/-/* on a distinct type.)
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.
Mutable fields
A field of a shared struct is read-only unless you declare it mut. Because a
shared value can have several owners, mutation through a shared reference is
opt-in per field — marking a field mut is how you say "this one is meant to
change in place":
shared struct ListNode {
val: i64,
mut next: Option[ListNode], // reassignable; `val` is not
}
(Plain value structs don't need this — there you control mutation with a mut
binding, let mut p = Point { ... }. The per-field mut is specific to
shared, where the binding alone can't decide it.)
A linked list
The recursive shared struct + Option pair is the standard singly-linked
list — each node owns the next, and None marks the end:
shared struct ListNode {
val: i64,
mut next: Option[ListNode],
}
// Build a list from a slice, tail to head, preserving order.
fn from_slice(xs: Slice[i64]) -> Option[ListNode] {
let mut head: Option[ListNode] = None;
let mut i = xs.len() - 1;
while i >= 0 {
head = Some(ListNode { val: xs[i], next: head });
i = i - 1;
}
head
}
Walk it by unwrapping each node. if let peels off one Some and recurses on
.next; while let does the same thing iteratively, rebinding the cursor until
it hits None:
fn sum(node: Option[ListNode]) -> i64 {
if let Some(n) = node {
n.val + sum(n.next)
} else {
0i64
}
}
fn length(node: Option[ListNode]) -> i64 {
let mut count = 0i64;
let mut cur = node;
while let Some(n) = cur {
count = count + 1;
cur = n.next;
}
count
}
Trees are the same shape with two children instead of one (mut left,
mut right) — see the TreeNode example in Ownership.
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_letter(text: Option[String]) -> Option[String] {
let t = text?; // if None, return None early
if t.len() == 0 { return None; }
Some(t.substring(0, 1))
}
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.
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: ref Vec[T]) -> Option[ref 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 mut sorted = items;
sorted.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.
Collections
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
Capacity — when you know the size, reserve it
A Vec.new() starts empty and grows by reallocation as you push: capacity
goes 0 → 1 → 2 → 4 → 8 → …, and each doubling copies every element it already
holds to a fresh, larger buffer. On allocation-bound code that builds a
bounded-size Vec, that grow chain is the dominant cost.
When you already know how many elements you'll add, reserve the space up front
with Vec.with_capacity(n). It allocates once; the first n pushes land in the
reserved slots without a single reallocation:
let mut out = Vec.with_capacity(n + 1); // one allocation, no grow chain
let mut i = 0;
while i < n {
out.push(compute(i));
i = i + 1;
}
with_capacity is only a hint — the Vec still grows if you exceed n, so an
imperfect estimate can never change your program's output, only its memory use.
Reach for it whenever you're building a bounded-size collection in a hand-written
loop and the bound is known before the loop starts.
The payoff is real: building a bounded Vec with Vec.new() + a push loop, then
switching that same loop to Vec.with_capacity(n), roughly halved the build
time on an allocation-bound benchmark — the whole difference was the
grow-from-empty tax, not the work itself.
You often don't have to write it, though. Two things already handle the common cases for you:
- A simple counted push loop —
while i < n { v.push(..) }with a known bound and an unconditional push — is pre-sized automatically; the compiler reserves the trip count for you, so the hand-written form above is only needed when the count isn't a clean loop bound. - The
collectidiom doesn't need it either.src.iter().map(..).collect()andsrc.iter().filter(..).collect()build their result with a tight grow loop that has no per-element bounds check, so they already run within a hair of a hand-tunedwith_capacity— reaching for a manual reservation there buys nothing (and can even cost you, since a fixed up-front allocation interacts worse with the cache when the source elements are large).
So the rule of thumb is narrow: use Vec.with_capacity for a hand-written loop
whose element count you know but that isn't a plain counted push. For counted
loops and for collect, write the natural code — it's already fast.
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())
}
Nested collections — grids
Collections nest. The workhorse 2D structure is a Vec[Vec[i64]] — a vector of
rows, each a vector of cells. Build one by pushing row literals:
let mut grid: Vec[Vec[i64]] = Vec.new();
grid.push([1, 2, 3]);
grid.push([4, 5, 6]);
println(grid.len()); // 2 — number of rows
println(grid[0].len()); // 3 — number of columns
println(grid[1][2]); // 6 — row 1, column 2
grid[i][j] reads a cell; the same place assigns to one:
grid[0][1] = 20; // mutate one cell in place
For a grid sized at runtime — the usual starting point for a dynamic-programming
table — fill it with zeros up front. Vec.filled(n, value) builds an n-element
vector, and nesting it gives an r × c grid:
let mut dp: Vec[Vec[i64]] = Vec.filled(rows, Vec.filled(cols, 0i64));
Each row is an independent copy — writing dp[0][0] = 9 leaves dp[1][0]
untouched. (Collections have value semantics; the inner Vec.filled is copied
into each slot, not shared.)
Traverse a grid by index. Pull each row out with let row = g[i], then walk its
cells — exactly the shape the inner loop wants:
fn cell_sum(g: ref Vec[Vec[i64]]) -> i64 {
let rows = g.len();
let mut total = 0i64;
let mut i = 0i64;
while i < rows {
let row = g[i];
let cols = row.len();
let mut j = 0i64;
while j < cols {
total = total + row[j];
j = j + 1;
}
i = i + 1;
}
total
}
The parameter is ref Vec[Vec[i64]] — the grid is borrowed for reading, so the
caller keeps ownership. To mutate cells through a parameter, take mut ref Vec[Vec[i64]] (see Ownership).
Rows need not be the same length — a Vec[Vec[i64]] is naturally jagged, which
is what you want for adjacency lists and triangular tables.
Strings and Bytes
Kāra strings are UTF-8. A String owns a growable, heap-allocated buffer of
UTF-8 bytes; a StringSlice is a borrowed view into one (see
Variables and Types). Because the
encoding is UTF-8, a character can be one to four bytes wide — and that single
fact shapes the whole API.
You can't index a String directly
let s = "hello";
// let c = s[0]; // not allowed
s[i] would have to either return a byte (surprising — you asked for a
character) or scan from the start counting characters (a hidden O(n) cost on
something that looks like O(1)). Kāra refuses to guess. Instead you pick a
view and the cost becomes explicit:
- Characters —
s.chars()/s.char_at(i), working in Unicode scalar values. - Bytes —
s.bytes(), working in rawu8with O(1) indexing.
Algorithmic code over ASCII almost always wants the byte view; text that has to respect non-ASCII characters wants the character view.
Substrings — a range index copies
Scalar indexing is out, but a range index is in — and it means something
different from range-indexing a Vec. s[a..b] returns a fresh, owned
String holding a copy of the bytes in that range (not a borrowed Slice,
and not a StringSlice view):
let s = "HelloWorld";
let head = s[0..5]; // "Hello" — a new String
let tail = s[5..]; // "World"
let all = s[..]; // "HelloWorld" (a full copy)
Every range form works — a..b, a..=b, a.., ..b, ... The method
spelling s.substring(a, b) is identical in behaviour to s[a..b].
Two things to keep in mind:
-
The offsets are byte positions, not character positions — the same units as
s.bytes(). For ASCII the two coincide; for text with multi-byte characters they don't. -
A range must land on UTF-8 character boundaries. Slicing through the middle of a multi-byte character panics at runtime (
E_STRING_SLICE_NOT_AT_CHAR_BOUNDARY) rather than handing back invalid UTF-8:let s = "héllo"; // 'é' occupies bytes 1..3 let bad = s[0..2]; // panics — byte 2 is mid-character
Because the result is an owned String, it outlives the source and can be
pushed, returned, or stored freely. When you only need to read a borrowed
window without copying, reach for a StringSlice via s.slice(a, b) instead
(see Variables and Types).
The character view
for c in s and s.chars() both yield char — one Unicode scalar value at a
time:
let mut vowels = 0;
for c in s.chars() {
if c == 'a' or c == 'e' or c == 'i' or c == 'o' or c == 'u' {
vowels = vowels + 1;
}
}
For random access, snapshot the characters into a Vec[char] once, then index
that:
let chars: Vec[char] = s.chars().collect();
println(chars[0]); // 'h' — a char
println(chars.len()); // 5
s.char_at(i) returns the i-th character as an Option[char] — None when i
is out of range — and is O(n), since it counts characters from the front. Reach
for it for a one-off lookup; if you index repeatedly, collect into a Vec[char]
instead.
The byte view — s.bytes()
s.bytes() returns a Slice[u8]: a borrowed, O(1)-indexable view over the
string's underlying storage, with no per-call allocation. This is the workhorse
for scanning ASCII input.
let bytes = s.bytes();
let n = bytes.len();
let b = bytes[0]; // u8
Byte literals
A b'x' literal is a single ASCII byte — a u8, not a char. Compare and do
arithmetic on bytes directly:
let b = bytes[i];
if b >= b'0' and b <= b'9' {
let digit = (b - b'0') as i64; // '7' - '0' == 7
}
b - b'0' — the gap between a digit's byte and the byte for '0' — is the
canonical "parse one ASCII digit" move. The same range trick classifies letters
(b >= b'a' and b <= b'z').
Worked example: Roman numerals
Scanning bytes left to right, subtracting a smaller value that precedes a larger
one (IV is 4):
fn value(b: u8) -> i64 {
if b == b'I' { return 1i64; }
if b == b'V' { return 5i64; }
if b == b'X' { return 10i64; }
if b == b'L' { return 50i64; }
if b == b'C' { return 100i64; }
if b == b'D' { return 500i64; }
if b == b'M' { return 1000i64; }
0i64
}
fn roman_to_int(s: ref String) -> i64 {
let bytes = s.bytes();
let n = bytes.len();
let mut total = 0i64;
let mut i = 0i64;
while i < n {
let cur = value(bytes[i]);
if i + 1 < n and cur < value(bytes[i + 1]) {
total = total - cur;
} else {
total = total + cur;
}
i = i + 1;
}
total
}
Note the parameter is ref String — the function borrows the string to read it,
it doesn't take ownership (see Ownership). A string
literal passes straight to a ref String parameter, so roman_to_int("MCMXCIV")
just works.
Matching on bytes
Because a byte is a plain integer, match arms can be byte literals — handy when
one byte maps to another:
fn closer_for(b: u8) -> u8 {
match b {
b'(' => b')',
b'[' => b']',
b'{' => b'}',
_ => 0u8,
}
}
Building strings
You scan with bytes, but you build with characters. Start from an empty
String and append:
let mut out = String.new();
out.push('h'); // push a single char
out.push_str("ello"); // append a string
push takes a char, so you build from char literals or from characters you
pulled out with .chars():
fn reverse(s: ref String) -> String {
let chars: Vec[char] = s.chars().collect();
let n = chars.len();
let mut out = String.new();
let mut i = n - 1;
while i >= 0 {
out.push(chars[i]);
i = i - 1;
}
out
}
A u8 is not a char — b as char is rejected, because not every integer
is a valid Unicode scalar. When you need a character computed from a number, go
through char.try_from, which returns a Result[char, _]:
fn digit_char(d: i64) -> char {
match char.try_from(b'0' + d as u8) {
Ok(c) => c,
Err(_) => '?',
}
}
Which view should I use?
| You want… | Use | Cost | Element |
|---|---|---|---|
| Scan/parse ASCII left to right | s.bytes() then index | O(1) per access | u8 |
| Iterate characters once | for c in s.chars() | O(n) total | char |
| Random access by character index | s.chars().collect() → Vec[char] | O(n) once, O(1) after | char |
| One-off i-th character | s.char_at(i) | O(n) | Option[char] |
| A copied substring (byte range) | s[a..b] / s.substring(a, b) | O(k) copy | String |
| A borrowed substring view | s.slice(a, b) | O(1) | StringSlice |
| Build up output | String.new() + push / push_str | amortized O(1) per push | — |
The rule of thumb: read as bytes, build with chars. Byte scanning keeps the inner loop to single-byte comparisons; character building keeps the output UTF-8-correct without you tracking encoding by hand.
Closures and Iterators
Closures
Anonymous functions that can capture variables from their environment:
let add = |a: i64, b: i64| a + b;
println(add(2, 3)); // 5
let threshold = 10;
let is_big = |n: i64| n > threshold; // captures `threshold`
println(is_big(15)); // true
Closure parameters take type annotations just like fn parameters. When a closure is passed straight into a combinator like .map(), the element type flows in and the annotation can be omitted (see Iterators below); a closure bound to a let with no other context needs the annotation.
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.
Sorting
The most common place you'll hand a closure to the standard library is sorting.
Vec sorts in place, so the binding must be mut:
let mut v = [3, 1, 4, 1, 5, 9, 2, 6];
v.sort(); // natural ascending order
For any other order, sort_by takes a comparator closure |a, b| ... that
returns an ordering. Produce one by comparing two elements with .cmp():
v.sort_by(|a, b| a.cmp(b)); // ascending — same as v.sort()
v.sort_by(|a, b| b.cmp(a)); // descending — flip the operands
a.cmp(b) answers "how does a order relative to b?", so putting b first
reverses the direction. The same shape sorts by a derived key — compare the keys
instead of the whole elements:
// pairs: Vec[(i64, i64)] — order by the second component, descending
pairs.sort_by(|a, b| b.1.cmp(a.1));
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]
Because closures carry the effects of their bodies (see above), iterator chains stay honest about effects too: a map whose closure writes to stdout contributes writes(Stdout) to the enclosing function's effect row. The same map / filter / take combinators apply whether the elements come from an in-memory Vec or an effectful source — 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)
These six are the resource verbs: reads, writes, sends, receives, allocates, panics. They answer "can these two operations conflict?" — which is what drives the auto-parallelization below.
Resources are user-defined. You declare what exists in your domain:
effect resource FileSystem;
effect resource Database;
effect resource Cache;
effect resource Net;
One verb can name several resources at once — writes(Display, Audio) is shorter than writing writes twice.
Execution verbs: blocks and suspends
The resource verbs say what a function touches. Two more verbs say how it runs — information the scheduler needs to decide where to place a task:
blocks— the call may park the OS thread in a kernel wait (asleep, a synchronous file read, a contended lock). While it waits, that thread can do nothing else, so the scheduler routes blocking tasks to a separate pool.suspends— the call may cooperatively yield: the task steps aside and the thread is freed to run other work, resuming later. This is Kāra's async — there is noasync/await, noFuture, no function coloring. You write a plain call; the compiler inserts the yield point because the callee is declaredsuspends.
fn sleep(d: Duration) with blocks { ... } // parks the thread
fn http_get(url: String) -> Response with sends(Net) suspends { ... } // yields while waiting
fn compute(x: f64) -> f64 { ... } // neither — runs anywhere
Execution verbs take no resource — a function either may block/suspend or it may not. They don't take part in conflict analysis (placement is a separate axis from conflict), and like resource verbs they're inferred on private functions but must be declared on public ones: hiding whether a function blocks would defeat the point.
That's the full set: six resource verbs plus these two execution verbs, eight in all.
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);
Effect group names are Value-class (snake_case) — the same naming class as effect verbs and let bindings. 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 add_suffix(name: mut ref String) {
name.push_str("!"); // 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.
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
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:
A par block runs each of its branches concurrently and waits for all of them before control falls through. It's structured concurrency — no dangling tasks, no fire-and-forget. Each branch is an independent statement; they share data through a concurrency-safe type rather than by writing the same plain variable:
par struct Counter { count: Atomic[i64] }
fn bump(counter: ref Counter) {
let _ = counter.count.fetch_add(1, MemoryOrdering.Relaxed);
}
fn main() {
let counter: Counter = Counter { count: Atomic.new(0) };
par {
bump(counter);
bump(counter);
bump(counter);
}
println(counter.count.load(MemoryOrdering.Relaxed)); // 3
}
par struct marks a type as safe to share across branches; its Atomic[T] field carries the shared state. The effect system enforces this: if a par branch tried to write an ordinary let mut shared by a sibling, the compiler would reject it and tell you to reach for Atomic, Mutex, or a par struct. No data races slip through.
TaskGroup for dynamic fan-out
par is for a fixed set of branches written out in the source. When the number of tasks is decided at runtime — split an image into workers bands, process N rows — use a TaskGroup: spawn each task, collect its TaskHandle[T], then join each to gather the results.
fn square(n: i64) -> i64 { n * n }
fn main() {
let mut pool: TaskGroup = TaskGroup.new();
let mut handles: Vec[TaskHandle[i64]] = Vec.new();
let mut k = 1;
while k <= 4 {
let n = k; // a fresh binding per task
handles.push(pool.spawn(|| square(n)));
k = k + 1;
}
let mut total = 0i64;
for handle in handles {
total = total + handle.join(); // wait for each, collect its result
}
println(total); // 1 + 4 + 9 + 16 = 30
}
pool.spawn takes a thunk (a zero-argument closure) and returns a TaskHandle[T]; .join() waits for that task and hands back its return value. Bind a fresh let n = k inside the loop so each closure captures its own value rather than the shared loop counter. There is no async/await — a spawned task is just a function call that happens elsewhere, with suspends tracking any cooperative yielding (see Effects).
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
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 reorganizes the memory of a collection — a Vec[T] or
Array[T, N] — without touching the struct definition or the code that uses it.
You attach it to a binding, not to the type: layout <name>: Vec[T] { ... }.
struct Particle { x: f64, y: f64, name: String }
// x and y share one contiguous array (the physics hot path);
// name is cold — a separate allocation the hot loop never touches.
layout swarm: Vec[Particle] {
group hot { x, y }
cold { name }
}
Now swarm's storage is Structure-of-Arrays: all the xs and ys sit
together in the hot group's backing array instead of being interleaved with
each name. The logical API is unchanged — you still write swarm[i].x — and a
loop that reads only x and y streams through dense memory:
fn drift(swarm: ref Vec[Particle]) -> f64 {
let mut sum = 0.0;
let mut i = 0i64;
while i < swarm.len() {
sum = sum + swarm[i].x + swarm[i].y; // touches only the hot group
i = i + 1;
}
sum
}
Three directives go inside the block:
group <name> { fields }— the named fields become one contiguous array (the SoA transform). Use several groups to keep fields that are read together on the same cache line.cold { fields }— moves rarely-accessed fields to a separate allocation, out of the hot path. At most onecoldsection per block.align(N)— forces a group's backing array onto anN-byte boundary (e.g.align(64)for a cache line), the standard fix for false sharing between threads.
Every field must be placed in exactly one group or in cold — the compiler
rejects a layout that leaves a field unassigned, so the storage is never
ambiguous. There is no soa keyword: grouping a collection's fields is the SoA
transform, and the default (no layout block) is plain array-of-structs.
Because a single element's fields are now scattered across the group arrays, no
one contiguous region is a whole Particle — so you can't borrow a whole
element out of an SoA collection. Reading a field (swarm[i].x) works, and you
can always materialize a plain array-of-structs copy of one element:
let e = swarm[1]; // an array-of-structs copy of one element
println(e.name);
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
Kāra has built-in testing support — no external framework needed.
Unit tests
Tests live alongside the code they test, in _test.kara files. Each test is a test "name" { ... } block — a quoted case name and a body. karac test discovers and runs every such block; no attribute or registration required:
// math.kara
pub fn add(a: i64, b: i64) -> i64 {
a + b
}
// math_test.kara — shares module `math`'s scope, so it calls `add` directly
test "addition works" {
assert_eq(add(2, 3), 5);
assert_eq(add(-1, 1), 0);
}
test "addition is commutative" {
assert_eq(add(3, 7), add(7, 3));
}
A <module>_test.kara file is part of that module, so it sees the module's
functions with no import — in fact importing your own module back into its
test file is a cycle error. The case name is a string, not an identifier, so it
can read like a sentence.
Assertions
Available everywhere as builtins:
assert(condition); // panics if false
assert_eq(left, right); // panics if not equal, shows both values
Running tests
karac test # run all tests
karac test addition # run only tests whose case name contains "addition"
The filter is a substring of the case name — the text between test and { —
so karac test commutative runs just the second block above.
Server-Side Rendering
A single Kāra program often compiles to more than one target. The classic case is server-side rendering (SSR): the server renders a page to HTML, the browser hydrates it and handles interaction — and you want one component to drive both, not two copies that drift apart.
Kāra does this without #[cfg] chains in your component. The component
stays an ordinary, target-agnostic function. What differs between server
and client is which provider you bind for its resources.
The full, runnable code for this chapter is
examples/ssr_counter.
The shared component
The component renders against an abstract resource, Sink, rather than
talking to a concrete HTML buffer or DOM. It has no idea which target it is
running on — and no #[target(...)] attribute:
effect resource Sink;
// Target-agnostic: compiles unchanged for the server and the client.
pub fn render_counter(count: i64) with writes(Sink) {
Sink.heading("Kāra SSR Counter");
Sink.count(count);
Sink.parity(count % 2);
}
render_counter issues semantic render calls. Turning those into bytes
or DOM mutations is somebody else's job — the provider's.
Two providers, one resource
A provider is just a type whose methods realize the resource. On the
server, Sink becomes HTML:
struct StringSink {}
impl StringSink {
fn heading(mut ref self, title: String) { print(f"<h1>{title}</h1>"); }
fn count(mut ref self, n: i64) { print(f"<output id=\"count\">{n}</output>"); }
fn parity(mut ref self, p: i64) {
if p == 0 { println("<span id=\"parity\">even</span>"); }
else { println("<span id=\"parity\">odd</span>"); }
}
}
On the client, Sink becomes DOM mutation. The static heading is already
present in the page the server rendered, so hydration leaves it alone —
only the dynamic values cross to the host:
effect resource Dom;
host fn dom_set_count(value: i64) with writes(Dom);
host fn dom_set_parity(value: i64) with writes(Dom);
struct DomSink {}
impl DomSink {
fn heading(mut ref self, title: String) {} // already in the SSR'd DOM
fn count(mut ref self, n: i64) with writes(Dom) { dom_set_count(n); }
fn parity(mut ref self, p: i64) with writes(Dom) { dom_set_parity(p); }
}
The entry points — the only place #[target] belongs
Each target binds its provider with with_provider, then calls the same
component. The entry points are the one genuinely per-target part of the
program, so they — and only they — carry #[target(...)]:
// Server: render to HTML on stdout.
#[target(native)]
fn main() {
with_provider[Sink](StringSink {}, || {
render_counter(42);
});
}
// Client: hydrate the live DOM. `pub` + a matching target tag exports it
// to JavaScript.
#[target(wasm_browser)]
pub fn hydrate(count: i64) -> i64 with writes(Dom) {
with_provider[Sink](DomSink {}, || {
render_counter(count);
});
count
}
Build each target from the same file:
karac build ssr_counter.kara # ./ssr_counter (server)
karac build ssr_counter.kara --target=wasm_browser # .wasm + .js (client)
The server prints the page body; the browser loads the wasm, supplies the
DOM host fns, and calls hydrate. One component, rendered two ways.
The rule
Keep
#[target(...)]out of component bodies. The attribute is for entry points and irreducible forks — code that genuinely cannot exist on every target. Everything else is target-agnostic, and per-target behavior comes from the providers you bind.
This is not just style. Because the component is target-agnostic, the
compiler type-checks and effect-checks it once per target (see
the design notes on cross-target compilation).
A user-defined resource like Sink has no target affinity — it lives
wherever a provider for it does — so the same component is provably correct
on the server and the client without a single conditional.
The effect system also catches target mistakes for you. A function that
reaches a browser-only capability (say writes(Display)) cannot be
compiled for native; the compiler rejects it at the target gate and
points to the call chain — no silent misbuild, no runtime surprise.
FFI and Interop: Kāra as a Library
A new language has a bootstrapping problem: no ecosystem yet, and no team wants to rewrite a working system to get one feature. Kāra's answer is to be additive, not a replacement. Write the hot loop, the parallel kernel, the one function that has to be fast, in Kāra — build it as a linkable library with a plain C ABI — and drop it into a program that keeps everything else. This is the Rust-in-Firefox, Zig-alongside-C playbook, and Kāra is built to be the guest.
This chapter walks one kernel from .kara source into both a C and a Rust host, side by side. Every command and every line of output below is real — the two hosts print the same numbers, which is the whole point.
Calling C from Kāra
Before the main event, the other direction in one screen — importing C functions into Kāra. Declare them in an unsafe extern "C" block and call them inside unsafe:
/// # Safety
/// `sqrt` is pure math from libm; `strlen` reads only the NUL-terminated
/// bytes behind the pointer, which `c"..."` literals guarantee.
unsafe extern "C" {
fn sqrt(x: f64) -> f64;
fn strlen(s: *const u8) -> usize;
}
fn main() {
// Safety: finite input; sqrt has no preconditions.
let root = unsafe { sqrt(2.0) };
println(root); // 1.4142135623730951
let msg = c"hello from C land";
// Safety: msg is a static NUL-terminated literal.
let n = unsafe { strlen(msg.as_ptr()) };
println(n as i64); // 17
}
This works identically under karac run and karac build — the JIT resolves the symbols in-process, the AOT build links them from libc/libm.
Three rules carry most of the weight:
- The declaration block is
unsafe extern, and every call site isunsafe. The compiler can't check what's on the other side of the boundary, so both the block and each call carry a# Safety/// Safety:comment stating the trust contract — theundocumented_unsafelint warns when they're missing. - C strings cross as
*const u8. Ac"..."literal is aref CStr(NUL-terminated, in rodata); hand it to C with.as_ptr(). Declaring an extern parameter asref CStrdirectly is not the FFI form —CStris a Kāra type, not a C one. - Effects still apply. An extern fn that writes somewhere declares it:
fn puts(s: *const u8) -> i32 with writes(Console);— foreign code doesn't get to dodge the effect system (see Effects).
That's the import direction. The rest of this chapter is the export direction — the one that answers "can I actually use this next to my existing code?"
The kernel
A library has no main. It has an exported surface: the functions a caller is allowed to reach. In Kāra that surface is every pub extern "C" fn. Save this as kernel.kara:
#[repr(C)]
pub struct Stats { sum: f64, count: i64 }
// A simple scalar export.
pub extern "C" fn add(a: i32, b: i32) -> i32 { a + b }
// A little real work — iterative Fibonacci.
pub extern "C" fn fib(n: i64) -> i64 {
if n < 2 { return n; }
let mut a = 0;
let mut b = 1;
let mut i = 2;
while i <= n {
let c = a + b;
a = b;
b = c;
i = i + 1;
}
b
}
// A `#[repr(C)]` struct crosses the boundary by value.
pub extern "C" fn stats_mean(s: Stats) -> f64 {
if s.count == 0 { return 0.0; }
s.sum / (s.count as f64)
}
Two things worth pointing at:
pub extern "C" fnis the whole export declaration.pubmakes it visible;extern "C"gives it the C calling convention and an unmangled symbol name, so a C caller reachesaddasadd, not some mangled string.#[repr(C)]onStatsis a promise about memory layout. A default Kāra struct has no stable physical layout (the compiler is free to reorder fields — see Data Layout);#[repr(C)]pins it to the C order so it can cross the boundary by value.
Building the artifact
Two crate types, two artifacts:
$ karac build kernel.kara --crate-type staticlib
Built: libkernel.a
Built: libkernel.h
$ karac build kernel.kara --crate-type cdylib
Built: libkernel.so
Built: libkernel.h
staticlib produces a .a (a .lib on Windows); cdylib produces a .so (.dylib on macOS, .dll on Windows). Both come with libkernel.h — the C header, generated for you, so a caller #includes it instead of hand-transcribing signatures. The default artifact name is lib<stem>.<ext>, distinct from any executable, so a library build never clobbers a stray binary; -o overrides it.
The .a is thick: it bundles the Kāra runtime. A C program links it and runs with no karac toolchain present — that is the deliverable, a .a + a .h you can hand to a team that has never heard of Kāra.
The emitted header
Here's what karac wrote to libkernel.h (trimmed to the body):
#include <stdint.h>
#include <stddef.h>
/* Runtime lifecycle. Call karac_runtime_init() once before the first
* exported call, and karac_runtime_shutdown() at host teardown. */
void karac_runtime_init(void);
void karac_runtime_shutdown(void);
struct Stats {
double sum;
int64_t count;
};
int32_t add(int32_t a, int32_t b);
int64_t fib(int64_t n);
double stats_mean(struct Stats s);
The #[repr(C)] struct came across as a real C struct, the scalars mapped to fixed-width <stdint.h> types, and two lifecycle functions appeared that you didn't write — more on those next.
The C host
The host includes the header and calls in. Nothing from karac is on the compile line — just cc, the .a, and the .h:
#include <stdio.h>
#include "libkernel.h"
int main(void) {
karac_runtime_init();
struct Stats s = { .sum = 30.0, .count = 4 };
printf("add=%d fib=%lld mean=%.2f\n",
add(20, 22),
(long long)fib(20),
stats_mean(s));
karac_runtime_shutdown();
return 0;
}
$ cc host.c libkernel.a -lpthread -lm -ldl -o host_c
$ ./host_c
add=42 fib=6765 mean=7.50
karac_runtime_init() before the first exported call, karac_runtime_shutdown() at teardown — the runtime lifecycle bracket. At v1 they are no-ops, but calling them is the contract: it lets the runtime acquire and release whatever it needs without you rewriting the host later.
The Rust host — with one caveat
A Rust program consumes Kāra the same way it consumes any C library: an extern "C" block declaring the surface. This is the pyo3 / cxx / uniffi pattern, inverted — Rust reaching into Kāra across the stable C boundary. (There is no stable Rust ABI, so C is the durable bridge in both directions.)
#[repr(C)] struct Stats { sum: f64, count: i64 } #[link(name = "kernel", kind = "dylib")] extern "C" { fn karac_runtime_init(); fn karac_runtime_shutdown(); fn add(a: i32, b: i32) -> i32; fn fib(n: i64) -> i64; fn stats_mean(s: Stats) -> f64; } fn main() { unsafe { karac_runtime_init(); let s = Stats { sum: 30.0, count: 4 }; println!("add={} fib={} mean={:.2}", add(20, 22), fib(20), stats_mean(s)); karac_runtime_shutdown(); } }
$ karac build kernel.kara --crate-type cdylib -o libkernel.so
$ rustc host.rs -L . -C link-arg=-Wl,-rpath,. -o host_rs
$ ./host_rs
add=42 fib=6765 mean=7.50
Same three numbers as the C host. That is the A/B result: one kernel, two languages, identical output.
The caveat is in the build command: a Rust host must link the cdylib, not the staticlib. The Kāra runtime is itself a Rust crate that bundles std, so a .a carries std symbols — rust_eh_personality, the allocator shims — that collide with the Rust host's own std at static-link time, and you get a cryptic duplicate symbol error. A shared library encapsulates those internal symbols; the dynamic linker resolves only the exported entry points. karac prints a note steering you to the cdylib whenever you build a staticlib, and the caveat rides along in the header comment too. C and C++ hosts have no std to clash with and can link either artifact.
What crosses the boundary
The C ABI is honest about what it can carry. The type mapping is a deliberate v1 set:
- Primitives (
i32,i64,f64,bool, …) and raw pointers cross transparently — they are their C equivalents. #[repr(C)]structs cross by value, as you saw withStats.- Owned collections returned by value —
String,Vec[i32], and one level of nesting likeVec[String]— are auto-boxed. Kāra returns them as a small{data, len, cap}record, which doesn't match the C struct-return ABI, so the compiler heap-boxes the value and hands C an opaque pointer instead. The header gains a matching struct and akarac_free_<name>destructor; the C side reads the fields and calls the destructor when done. Zero boilerplate on your side. - Everything else — an
enum, anOption, a plain (non-repr(C)) struct by value — is rejected at build time with a clear error rather than silently miscompiled. If the offender is a user struct, the diagnostic points at the one-step fix: add#[repr(C)].
That last rule is the important one. The compiler will not emit a header that promises a shape the ABI can't actually deliver, so the .a / .so / .h you ship is never quietly wrong.
Ownership across the boundary
The simplest way to hand C a buffer it will own is to allocate it the way C expects — through malloc, imported as an unsafe extern "C" block — fill it with raw-pointer writes, and return the pointer. The caller frees it with free (or a Kāra export that calls free), and no Kāra destructor is ever involved:
unsafe extern "C" { fn malloc(n: usize) -> *mut i64; }
pub extern "C" fn make_squares(n: i64) -> *mut i64 with blocks {
let p: *mut i64 = unsafe { malloc((n as usize) * 8) };
let mut i: i64 = 0;
while i < n {
unsafe { p.offset(i).write(i * i); }
i = i + 1;
}
p
}
The raw-pointer methods — .offset(i), .write(v), .read() (and _unaligned / _volatile variants) — are the low-level toolkit for this, always inside unsafe. The with blocks on the signature is the effect system at work: calling a foreign function is treated as blocking, and a public function must declare the effects it carries — the compiler tells you exactly which to add if you forget.
When instead you're holding an owned Kāra value and want to release it to the caller without running its destructor, the forget primitive is the move-out. It consumes its argument and suppresses the drop Kāra would otherwise run:
forget(value); // Kāra will NOT drop `value`; ownership has left the language
Because forget takes its argument by value, the ownership checker and the drop machinery both agree the value left — there is no double-free to reason about. For the auto-boxed return types above, this whole handshake is generated for you; the manual tools here are the escape hatch when you're managing the memory yourself.
Effects at the boundary
An exported function's effects are part of its contract, and the header states them. The boundary is synchronous: a suspends function — one that would yield to the async scheduler — is rejected as an export (E0414), because there is no scheduler on a bare foreign thread to yield to. blocks is fine; panics is contained (a panic can't unwind across the C frame, so it aborts rather than corrupt the caller). The exported surface tells the truth about what it does, up front.
What's next
You've now seen both directions of the C boundary: this chapter produced a library for C and Rust to consume, and the effect and ownership chapters cover calling out to C from Kāra with unsafe extern blocks. The full worked example — kernel, C host, Rust host, and a README — lives in examples/interop/ in the source tree; the specification is design.md § Exported C ABI.
The pitch is simple: you don't have to adopt Kāra all at once. Start with one kernel, link it in, and let it earn the next one.
Handling Secrets
Credentials leak in boring, repeatable ways. A token ends up in a log line because someone println'd a struct for debugging. An API key rides into a crash report because the type derived Serialize. A session compare uses ==, and an attacker times the responses to recover the token one byte at a time. None of these are exotic — they are the default behavior of ordinary types, and that is the problem.
Kāra's answer is std.secret.Secret[T]: a wrapper that makes the leaky paths not compile. You still hold the value, you still use it — but printing it, serializing it, and comparing it with == are compile errors, and the one comparison you're allowed is constant-time.
Wrapping a value
Secret is not in the prelude. You import it explicitly:
import std.secret.{Secret};
fn main() {
let api_key = Secret.new("sk-live-abc123");
// ...
}
That import line is deliberate. std.secret is a gated module — code that never asks for it never sees the name — so an import std.secret at the top of a file is itself a signal in code review: this file handles sensitive material. You want that signal loud.
Reading it back
The wrapped value is reachable through exactly one read path, .expose():
import std.secret.{Secret};
fn main() {
let api_key = Secret.new("sk-live-abc123");
let raw = api_key.expose();
println(f"Authorization: Bearer {raw}");
}
Authorization: Bearer sk-live-abc123
.expose() is intentionally ugly and intentionally unique. It is not an operator, not a Deref, not an implicit coercion — it is a method with a name you can grep for. Every place a secret becomes an ordinary value is a .expose() call site, so "where do we touch the raw key?" is a text search, not an audit.
The accidents that no longer compile
The point of the wrapper is what it refuses to do. Comparing two secrets with == doesn't type-check:
let a = Secret.new("x");
let b = Secret.new("y");
println(a == b);
error[typecheck]: type 'Secret<String>' does not implement Eq; add #[derive(Eq)] to use == or !=
The suggestion to #[derive(Eq)] is a dead end on purpose — deriving it is itself blocked:
impl Display for Secret[String] {
fn fmt(ref self) -> String { "leak" }
}
error[E_SECRET_TRAIT_FORBIDDEN]: cannot implement `Display` for `Secret[T]` — the
wrapper deliberately withholds this trait so a secret cannot be printed,
serialized, or structurally compared by accident (see design.md § Secret Type).
Read the value with `.expose()` where you genuinely need it; for equality use
the constant-time `.ct_eq(...)`, and rely on the built-in `Zeroize`/`Drop` for wiping
The same rejection covers Debug, Display, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Deref, Borrow, AsRef, and Copy — every trait that would let a secret slip out through printing, wire transit, structural comparison, transparent unwrapping, or silent bit-duplication. There is no #[derive] that turns them back on.
Secrets inside structs
Most secrets don't travel alone — they sit in a config or a session struct. Deriving Display on a struct that contains a Secret field is fine; the field just renders as <redacted>:
import std.secret.{Secret};
#[derive(Display)]
struct Config { host: String, token: Secret[String] }
fn main() {
let c = Config { host: "db.internal", token: Secret.new("hunter2") };
println(c);
}
Config { host: db.internal, token: <redacted> }
So the reflexive "log the whole config" debugging move is safe by construction: the ordinary fields print, the secret doesn't. This holds everywhere the struct renders — println, .to_string(), an f-string — and through nesting, because each struct re-checks its own Secret fields.
Comparing in constant time
The one comparison a Secret permits is .ct_eq():
import std.secret.{Secret};
fn main() {
let expected = Secret.new("sk-live-abc123");
let presented = Secret.new("sk-live-abc123");
if expected.ct_eq(presented) {
println("tokens match");
} else {
println("tokens differ");
}
}
tokens match
Why not just ==? Because a normal string compare returns the instant it finds a differing byte. An attacker who can time your token check learns how many leading bytes were correct from the response latency, and walks the secret out one byte per round of guesses. .ct_eq() compares the whole length every time — it accumulates the differences and only then decides — so the timing carries no information about where two values diverge. This is the right primitive for tokens, HMAC tags, and CSRF values, and it's the reason Secret withholds == in the first place.
(Today .ct_eq() covers Secret[String] — the token/HMAC/CSRF case. Byte-array secrets are on the way.)
The posture: wrap at the type, not at the call site
The habit Kāra pushes you toward is to make the secret a Secret[T] at the boundary where it's born — the moment you read the env var, parse the request header, or load the key file — and to keep it wrapped for its whole lifetime. Every downstream function takes a Secret[String], not a String; the type flows through your program carrying its guarantees, and the only holes are the .expose() calls you can enumerate.
The opposite habit — passing a raw String around and being careful at each use — is the one that fails, because "be careful everywhere" is not a property a compiler can check. Wrapping at the type makes the safe path the default and the unsafe path a visible, greppable exception.
A note on zeroization. The language reference (
design.md § Secret Type) also specifies that aSecretwipes its bytes when it's dropped, so a freed buffer doesn't linger in memory. That behavior is being wired through the compiler's drop paths and is not complete yet — so don't rely on a droppedSecret's memory being zeroed today. Everything else in this chapter — the wrapper,.expose(), the trait blocklist,<redacted>rendering, and constant-time.ct_eq()— is enforced now.
The 3am Runbook: Debugging Crashes
You have been paged. A Kāra service crashed, and there is a JSON file waiting for you. This chapter is the short version of what to do next — enough to triage the common cases without reading the whole book at 3am.
When a Kāra program panics, its std.panic handler writes a structured crash
report — a single JSON file — and prints a short summary plus the file's path
to stderr. The default location is /tmp/kara-crash-{pid}-{timestamp}.json
(configurable via KARA_CRASH_DIR). The JSON is the load-bearing artifact: it
is a stable, versioned wire format that tools can dedupe and group across
compiler versions.
First move: render it
When you have a crash file, run:
karac debug /tmp/kara-crash-48213-20260723T024117.json
karac debug turns the JSON into a human-readable report. (Pass - to read
from stdin, e.g. curl … | karac debug -.) Everything below is read off that
rendered report. The single most useful habit: read the effect set first —
it usually tells you which subsystem to look at before you read a single line
of application code.
Worked example 1 — a panic that names its blast radius
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Kāra crash report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
panic: index out of bounds
at src/handlers.kara:42:18 (fetch_user_dashboard)
index out of bounds: the len is 3 but the index is 99
effects: reads(UserDB) + sends(Network)
logical stack:
▸ fetch_user_dashboard
src/handlers.kara:42:18 reads(UserDB) + sends(Network)
▸ handle_request
src/server.kara:88:5 reads(UserDB)
▸ main
src/main.kara:12:3 pure
parallel context:
panicked branch: par@spawn_site_42 (worker 3)
still running: render_sidebar, load_notifications
cancelled: fetch_activity at sends boundary
provider stack:
Db ← PostgresPool bound at src/main.kara:8:3
RC fallback:
`session` became RC at src/handlers.kara:40:9
reason: captured by closure with subsequent outer use
────────────────────────────────────────────────────────────
kara 0.1.0 (abc1234) · x86_64-unknown-linux-gnu · release · 2026
How to read it, top to bottom:
panic: index out of bounds— the kind. An index-out-of-bounds is a logic bug (a bad index), not an outage. You are looking for a length assumption that failed, not a down dependency.at src/handlers.kara:42:18— go straight here. Line 42 indexed a 3-element collection with99.effects: reads(UserDB) + sends(Network)— the blast radius. This code path touches the user database and the network. If the bad index came from data,UserDBis the place that data came from.logical stack— the call chain, each frame with its own effect summary. Notemainispure: the effects are introduced deeper in.parallel context— this ran inside aparblock. Two siblings were still running and one (fetch_activity) was cancelled by fail-fast. That is expected: one branch panicking cancels the rest.provider stack— theDbresource was bound to aPostgresPoolatmain.kara:8. If this were a connectivity problem, this is the provider you would check.
Verdict: logic bug at handlers.kara:42. Not a paging-worthy outage — file
a bug, patch the index.
Worked example 2 — a crash inside cleanup
The famously-hard case: a panic that happens while the program is already unwinding from another panic, during a destructor. C++ aborts here; Kāra captures it.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Kāra crash report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
panic: panic while unwinding (drop)
at src/cache.kara:71:5 (Session::drop)
channel closed: send on a disconnected receiver
effects: sends(Metrics)
logical stack:
▸ Session::drop
src/cache.kara:71:5 sends(Metrics)
▸ handle_checkout
src/checkout.kara:118:9 reads(OrderDB) + writes(OrderDB)
RC fallback:
`session` became RC at src/checkout.kara:96:13
reason: captured by a spawned closure with subsequent outer use — RC keeps it alive across the task boundary
caused by:
panic: index out of bounds
at src/checkout.kara:122:20 (handle_checkout)
index out of bounds: the len is 0 but the index is 0
effects: reads(OrderDB)
logical stack:
▸ handle_checkout
src/checkout.kara:122:20 reads(OrderDB)
────────────────────────────────────────────────────────────
kara 0.1.0 (abc1234) · aarch64-apple-darwin · release · 2026
How to read it:
panic: panic while unwinding (drop)— the second panic. This one fired inSession::dropwhile the stack was already unwinding.caused by:at the bottom — the original panic. Read this first: the root cause is the index-out-of-bounds atcheckout.kara:122, not the channel error. The drop-time panic is a symptom of the cleanup running during an already-failing request.RC fallback— the report tells you why theSessionwas still alive to be dropped here: the compiler chose an RC representation forsessionatcheckout.kara:96because a spawned closure captured it. When a panic crosses an implicitDropof an RC value, this annotation is how you find it without guessing. The fix for the secondary crash is usually makingSession::droptolerate a closed channel; the fix for the incident is thecaused_bybug.
Worked example 3 — one branch takes down a parallel batch
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Kāra crash report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
panic: parallel fail-fast cancellation
at src/ingest.kara:54:12 (parse_shard)
unwrap on Err value: ParseError { line: 3, msg: "unexpected end of input" }
effects: reads(Blob) + panics
logical stack:
▸ parse_shard
src/ingest.kara:54:12 reads(Blob) + panics
▸ ingest_batch
src/ingest.kara:40:3 reads(Blob) + writes(WarehouseDB)
parallel context:
panicked branch: par@spawn_site_17 (worker 2)
still running: parse_shard[0], parse_shard[1], parse_shard[3]
cancelled: parse_shard[0] at writes boundary
cancelled: parse_shard[1] at writes boundary
cancelled: parse_shard[3] at reads boundary
────────────────────────────────────────────────────────────
kara 0.1.0 (abc1234) · x86_64-unknown-linux-gnu · release · 2026
How to read it:
panic: parallel fail-fast cancellation— a branch of aparblock panicked, and Kāra cancelled its siblings (fail-fast). The panic itself is theunwrap on Err valueatingest.kara:54.parallel contextis the important part here. Worker 2 hit aParseErroron its shard. Three sibling shards were mid-flight and were cancelled — note where: two were cancelled at awritesboundary (they had not yet committed toWarehouseDB) and one at areadsboundary. That tells you the batch did not partially write: cancellation caught the writers before their effect boundary. No cleanup of half-written warehouse rows is needed.- Verdict: a single malformed shard (line 3, unexpected EOF) failed the whole
batch. Fix the input or make
parse_shardreturn aResultthe batch can skip, rather thanunwrap.
Common patterns — what the effect set is telling you
The effect line is a triage shortcut. Some patterns worth memorizing:
| Effect set on the panic frame | Where to look first |
|---|---|
panics + reads(SomeDB) | Data from that database — a bad value, an empty result indexed, a failed unwrap on a query. |
sends(Network) / receives(Network) on the frame | A downstream dependency — timeouts, closed connections, a service that is down. Check the provider stack for which endpoint. |
writes(SomeDB) present but the panic is par_fail_fast_cancel | Check the parallel context cancellation boundaries — did any sibling get past its writes boundary? If not, no partial write happened. |
drop_during_unwind | Read caused_by first — the root cause is the original panic, not the drop. |
rc_fallback_borrow, or an RC fallback annotation on the crash | A shared value's lifetime crossed a task or closure boundary; the annotation names where the compiler chose RC. |
Escalation
If the rendered report is not enough — you want to attach it to a bug, feed it to an AI agent, or diff two crashes — re-emit the structured form:
karac debug crash.json --output=json
This prints the parsed report as pretty JSON. Because the wire format is stable,
it is safe to store, diff (karac debug a.json --output=json | diff - <(karac debug b.json --output=json)), or hand to tooling that keys on panic_kind and
panic_site for grouping. Tools dedupe on the (panic_kind, panic_site) pair,
so two reports with the same kind and site are "the same bug" even across
builds.
For the full field-by-field contract — every field, the panic-kind vocabulary, the edge cases (concurrent panics, WASM, GPU) — see the language reference's Crash Report Format section. This runbook is deliberately the short version.
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 | Reserved — modules come from the directory tree; a mod declaration is a compile error |
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 (reserved; not yet implemented in v1) |
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 |
These contract keywords are reserved and parsed, but contract checking is not yet enforced in v1 — runtime/compile-time verification is planned.
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. For explicit two's-complement wraparound, use the named wrapping_* methods (wrapping_add, wrapping_sub, wrapping_mul):
| Method | Behavior |
|---|---|
a.wrapping_add(b) | Two's-complement wraparound |
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 and b | Short-circuit logical AND |
a or b | Short-circuit logical OR |
not a | Logical NOT |
Kāra spells these as words. The symbolic forms && / \|\| / ! are rejected with a diagnostic that points you to and / or / 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 |
_ | 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 |
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 GridCell {
row: i64,
col: i64,
}
(Note the field types are integers. Eq and Hash require every field to be Eq/Hash, and f64 is neither — NaN != NaN breaks reflexivity — so a struct with f64 fields can derive PartialEq/PartialOrd/Display but not Eq/Ord/Hash. See the per-trait requirements below.)
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).
#[derive(Arithmetic)]
distinct type Metres = f64; // now Metres + Metres → Metres
Only valid on distinct types.
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 |
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) |
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;
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
#[profile(P1, P2, ...)]
On a function: asserts that its transitive effect set is compatible with the intersection of the listed profiles' constraints — the function must satisfy the strictest constraint from any listed profile. The v1 profile names are default (forbids nothing), embedded (forbids allocates(Heap)), and kernel (forbids allocates(*), panics, blocks, suspends). A forbidden effect in the function's declared or inferred set is error[E_PROFILE_INCOMPATIBLE_EFFECT]; an unknown profile name is error[E_UNKNOWN_PROFILE].
#[profile(embedded, kernel)]
fn scale(x: i64, factor: i64) -> i64 {
x * factor
}
#[no_effect(VERB, ...)]
On a function: asserts the named effects are absent from its transitive effect set. The arguments are effect verbs in the same grammar a with clause uses. It is the per-function counterpart of #[profile(...)] — that one inherits a target environment's forbidden set, this one names the forbidden effects directly and is independent of any profile, which is what makes it usable on the default profile where allocates(Heap) is permitted.
A bare verb forbids every occurrence of it; a verb with a resource narrows it to that resource, so #[no_effect(allocates(Heap))] still permits allocates(Arena). A forbidden effect in the declared or inferred set is error[E_NO_EFFECT_VIOLATED]. The attribute is fn-only (error[E_NO_EFFECT_INVALID_TARGET] elsewhere), and an empty list is rejected rather than treated as a vacuous guarantee.
#[no_effect(allocates(Heap), panics)]
fn mix(a: f32, b: f32) -> f32 {
a * 0.5 + b * 0.5
}
#[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 { ... }
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).
#[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.