Skip to content
Get Started

Syntax Basics

Functions are defined with fn and use Hindley-Milner type inference:

fn add(x: Int, y: Int) -> Int {
x + y
}
// Type annotations are optional when inferable
fn double(x) {
x * 2
}
// Single-expression functions (block with implicit return)
fn square(x: Int) -> Int = x * x

let introduces immutable bindings. The type is inferred unless annotated:

let x = 42
let y: Int = x + 8
let pair = (x, y) // Tuple: (Int, Int)

if is an expression — it returns a value:

let x = 42
let status = if x > 0 then "positive" else "non-positive"

Pattern matching on variants, records, and primitives:

type Option[T] = Some(T) | None
fn unwrap_or(o: Option[Int], default: Int) -> Int {
match o {
Some(v) => v,
None => default
}
}

Match arms can have guards:

let value = 7
match value {
n if n > 0 => "positive",
n if n < 0 => "negative",
_ => "zero"
}

Records are row-polymorphic — a function can accept any record with the fields it needs. When the parameter type is inferred (no annotation), the row stays open and extra fields are fine:

fn full_name(r) {
r.first + " " + r.last
}
let person = { first: "Alice", last: "Smith", age: 30 }
perform IO.print(full_name(person)) // "Alice Smith" — extra fields are fine

An explicit annotation like fn f(r: { first: String, last: String }) creates a closed record that rejects extra fields. Use inferred parameters for row-polymorphic functions.

Records are structurally typed and created with { field: value, ... } syntax.

Algebraic data types defined with type ... = ... | ...:

type Result[T, E] = Ok(T) | Err(E)
type Tree[T] = Leaf | Node((Tree[T], T, Tree[T]))

Construct and match:

let ok = Ok(42)
match ok {
Ok(v) => "Got " + perform Int.to_string(v),
Error(e) => "Error: " + e
}
let nums = [1, 2, 3, 4, 5]
let first = nums[0] // Index access (out-of-bounds returns nil)

Array length is available via the built-in Array.length effect:

let nums = [1, 2, 3, 4, 5]
let n = perform Array.length(nums) // 5

for loops also iterate over the full array without an explicit length call.

The |> operator pipes a value left-to-right into a function:

let inc = fn(x) { x + 1 } in
let dbl = fn(x) { x * 2 } in
1 |> inc |> dbl // 4

x |> f is equivalent to f(x). Chaining a |> f |> g |> h applies f, then g, then h in order.

There are two syntaxes for sending a message to an actor:

actor Counter {
behavior inc() { 0 }
}
let counter = spawn Counter {}
// Keyword form
send counter inc()
// Operator form (equivalent)
counter ! inc()

Both parse to the same AST. The ! form is more concise; the send form is more readable for complex arguments:

actor Counter {
state count: Int = 0
behavior inc_by(n: Int) { self.count = self.count + n }
behavior watch(v: Int) { 0 }
behavior get() { self.count }
}
let counter = spawn Counter {}
let w = spawn Counter {}
w ! watch(7)
send counter inc_by(5)
send counter get()

ask is a synchronous request/reply call to an agent or actor behavior. The caller blocks until the target responds:

actor Assistant {
behavior ask(q: String) -> String { "answer to " + q }
}
let a = spawn Assistant {} in
ask a ask("What is an actor model?")

See AI Agents for agent declarations and tool binding.

if can be used inline with the then keyword:

let fib = fn(n) {
if n <= 1 then n else fib(n - 1) + fib(n - 2)
} in fib(10)

if cond then a else b returns a when cond is truthy, b otherwise. The block form (if cond { a } else { b }) is equivalent.

Function signatures declare their effects with ! or throws followed by an effect row:

// Pure function — no effects
fn add(x: Int, y: Int) -> Int = x + y
// Effectful — performs IO (effect row after return type)
fn greet() -> Unit ! {IO} {
perform IO.print("Hello")
}
// throws is an alias for !
fn log(msg: String) -> Unit throws {IO} {
perform IO.print(msg)
}

See Algebraic Effects for the full effect system.

// Single-line comment
//// Regular comment (not a doc comment)
/// Doc comment — attaches to the next declaration
//! Module-level doc comment

Use exactly /// for declaration docs — the lexer recognizes it as a doc comment, and consecutive /// lines attach to the declaration that immediately follows them. //! lines form the module overview in generated docs (nulang --doc).