Oak Language
A statically-typed, expression-oriented language designed for readability and ergonomics. Draws from Crystal, Swift, Elixir, and JavaScript.
Hello, World
func main() {
IO.puts("Hello, world!")
}
Hello, world!
Key Principles
- Immutable by default — all bindings use
let; no mutation. - Expression-oriented —
if,switch, and blocks all return values. - Named arguments — Swift-style external labels at call sites.
- Module-organized — functions live in named modules with explicit arguments; no implicit
self. - Pipeline operator —
|>threads values left-to-right. - Global constructors — any capitalized identifier is a valid constructor value.
Built with an LLM
I've dreamt of building languages for years. My biggest hurdle has been taking the time and effort to learn the necessary patterns to build the kind of language I'd want to make. I realized now (in early 2026) that I can use popular LLMs to leapfrog that problem. I designed all parts of the language (syntax, semantics, usage, etc), but the LLM implemented them. The LLM wrote the docs (except for this small section, written 100% by a human). The LLM constructed the playground component and setup the example code snippets.
I steadfastly don't recommend using this language in critical programs at all, since I haven't personally vetted it. But it should be stable enough to use for small one-off scripts and for exploring alternative syntax and its benefits.
Bindings
All bindings are declared with let and are immutable.
func main() {
let x = 5
let name: String = "Alice" // explicit annotation is optional
IO.puts(name)
IO.puts(String(x))
}
Alice
5
Type annotations use name: Type syntax and are inferred when omitted.
Destructuring
Arrays can be destructured in let bindings. Use ...name to capture the remaining elements as a slice:
func main() {
let [first, second] = [1, 2, 3]
IO.inspect(first) // 1
let [head, ...tail] = [1, 2, 3]
IO.inspect(head) // 1
IO.inspect(tail) // [2, 3]
let [...most, last] = [1, 2, 3]
IO.inspect(most) // [1, 2]
IO.inspect(last) // 3
}
1
1
[2, 3]
[1, 2]
3
Use _ to discard an element:
func main() {
let [_, second, ...] = [10, 20, 30, 40]
IO.inspect(second) // 20
}
20
Entry Point
All programs must define a main function. Top-level let bindings are allowed; top-level imperative statements (like bare function calls) are not.
let greeting = "Hello from Oak" // top-level let binding is allowed
func main() {
IO.puts(greeting)
}
Hello from Oak
Functions
Declaration
func greet(name: String) -> String {
"Hello, \(name)!"
}
func main() {
IO.puts(greet(name: "Alice"))
IO.puts(greet(name: "Bob"))
}
Hello, Alice!
Hello, Bob!
Argument Labels
External and internal names can differ (Swift-style). Use _ as the external label to allow unlabeled call sites.
func move(from source: String, to destination: String) -> String {
"\(source) → \(destination)"
}
func double(_ x: Integer) -> Integer { x * 2 }
func main() {
IO.puts(move(from: "A", to: "B")) // call site uses external labels
IO.puts(String(double(5))) // no label required
}
A → B
10
Nested Functions
Functions can be defined inside other functions. This is the idiomatic way to express recursion without mutation.
func factorial(_ n: Integer) -> Integer {
func go(_ n: Integer, _ acc: Integer) -> Integer {
if n <= 1 { acc } else { go(n - 1, n * acc) }
}
go(n, 1)
}
func main() {
IO.puts(String(factorial(5)))
IO.puts(String(factorial(10)))
}
120
3628800
Function References
Named functions are first-class values and can be passed directly:
func isGt3(_ n: Integer) -> Bool { n > 3 }
func double(_ n: Integer) -> Integer { n * 2 }
func main() {
let values = [3, 1, 4, 1, 5, 9]
IO.inspect(Array.sorted(values))
IO.inspect(Array.filter(values, isGt3))
IO.inspect(Array.map(values, double))
}
[1, 1, 3, 4, 5, 9]
[4, 5, 9]
[6, 2, 8, 2, 10, 18]
Trailing Blocks
Any function can receive its final argument as a trailing block.
struct Person {
name: String
age: Integer
}
let people = [
Person(name: "Alice", age: 30),
Person(name: "Bob", age: 17),
Person(name: "Carol", age: 25)
]
func isAdult(_ p: Person) -> Bool { p.age >= 18 }
func getName(_ p: Person) -> String { p.name }
func main() {
let adults = Array.filter(people, isAdult)
IO.inspect(Array.map(adults, getName))
}
["Alice", "Carol"]
String Interpolation
Use \(expr) inside double-quoted strings.
func main() {
let name = "Alice"
IO.puts("Hello, \(name)!")
IO.puts("2 + 2 = \(2 + 2)")
}
Hello, Alice!
2 + 2 = 4
Types & Constructors
Constructors Are Global
Any capitalized identifier is a valid constructor value — no declaration required. Empty, None, ZeroCount — all are valid, everywhere.
func main() {
IO.inspect(Empty) // constructor value
IO.inspect(Value(42)) // constructor with one positional arg
IO.inspect(Error(msg: "oops")) // constructor with named arg
}
Empty
Value(42)
Error(msg: "oops")
Sum Types
The type keyword groups constructors into a named union.
type Direction = North | South | East | West
func describe(d: Direction) -> String {
switch(d) {
when North { "heading north" }
when South { "heading south" }
when East { "heading east" }
when West { "heading west" }
}
}
func main() {
IO.puts(describe(d: North))
IO.puts(describe(d: East))
}
heading north
heading east
Generic Types
Type parameters always use labeled syntax.
func main() {
let names: Array<Element: String> = ["Alice", "Bob"]
IO.inspect(Array.count(names))
}
2
Structs & Modules
Struct Declaration
Structs are value types with named fields. All fields are immutable. Structs are constructed by calling the type name with labeled arguments — no new keyword. Labels are required and may appear in any order.
struct Point {
x: Integer
y: Integer
}
func main() {
let p = Point(x: 3, y: 4)
let q = Point(y: 4, x: 3) // same value — label order doesn't matter
IO.puts("x=\(p.x), y=\(p.y)")
}
x=3, y=4
Self
Self inside a module block is syntactic sugar for the enclosing name. In a type position it refers to the module's type; as a function name it's a conventional way to write alternate constructors.
struct Point {
x: Integer
y: Integer
}
module Point {
func Self(onYAxis y: Integer) -> Self { Point(x: 0, y: y) }
}
func main() {
let p = Point.Self(onYAxis: 5)
IO.puts("x=\(p.x), y=\(p.y)")
}
x=0, y=5
Module Blocks
module is purely a function namespace — an organizational tool. There is no required coupling between a module name and a struct name. Functions that act on a type can live in any module.
struct Stats {
count: Integer
sum: Integer
min: Integer
max: Integer
}
func myAdd(_ a: Integer, _ b: Integer) -> Integer { a + b }
func myMin(_ a: Integer, _ b: Integer) -> Integer { if a < b { a } else { b } }
func myMax(_ a: Integer, _ b: Integer) -> Integer { if a > b { a } else { b } }
type StatsError = ZeroCount
module Stats {
func compute(from values: Array<Element: Integer>) throws StatsError -> Stats {
if Array.count(values) == 0 { throw ZeroCount }
Stats(
count: Array.count(values),
sum: Array.reduce(values, initial: 0, fn: myAdd),
min: Array.reduce(values, initial: values[0], fn: myMin),
max: Array.reduce(values, initial: values[0], fn: myMax)
)
}
}
func main() {
do {
let s = try Stats.compute(from: [4, 1, 9, 2, 7])
IO.puts("count=\(s.count), sum=\(s.sum), min=\(s.min), max=\(s.max)")
} catch ZeroCount {
IO.puts("empty input")
}
}
count=5, sum=23, min=1, max=9
Inline Module Syntax
module Foo { func bar() { } } groups functions under a shared namespace. Functions defined in separate module blocks with the same name are merged:
module String {
func shout(s: String) -> String { String.upcase(s) + "!" }
}
func main() {
IO.puts(String.shout(s: "hello"))
IO.puts(String.shout(s: "oak"))
}
HELLO!
OAK!
Control Flow
If / Else
if is an expression — it returns the value of the taken branch.
func main() {
let x = 5
if x > 0 {
IO.puts("positive")
} else {
IO.puts("non-positive")
}
let label = if x > 0 { "pos" } else { "non-pos" }
IO.puts(label)
}
positive
pos
for Loops
for loops over any array-like value. The loop body runs once per element:
func main() {
for item in ["a", "b", "c"] {
IO.puts(item)
}
}
a
b
c
return inside a for body exits the enclosing function.
switch
Both switch styles support an else arm as a catch-all:
type Status = Ok | Warn | Error
func main() {
let n = 7
let label = switch n {
when 1 { "one" }
when 2 { "two" }
else { "other" }
}
IO.puts(label)
}
other
Condition switch (no subject) also supports else:
func main() {
let score = 75
let grade = switch {
when score >= 90 { "A" }
when score >= 80 { "B" }
when score >= 70 { "C" }
else { "F" }
}
IO.puts(grade)
}
C
For type-based arms over a union, the compiler checks structural coverage — else is only required when not all variants are covered. For literal or expression arms, else is always required.
Pattern Matching
switch is an expression. Arms use when with curly-brace bodies. The compiler enforces exhaustiveness for union types.
Syntax
func parseLine(_ s: String) -> ParseResult {
let trimmed = String.trim(s)
if String.isEmpty(trimmed) {
Empty
} else {
do {
Value(n: Array.count(String.graphemes(trimmed)))
} catch {
Invalid(trimmed)
}
}
}
type ParseResult = Value(n: Integer) | Empty | Invalid(text: String)
func go(inputs: Array<Element: String>, acc: Array<Element: Integer>) -> Array<Element: Integer> {
if Array.isEmpty(inputs) {
acc
} else {
let input = inputs[0]
let rest = Array.slice(inputs, from: 1, to: Array.count(inputs))
let result = parseLine(input)
switch result {
when Value { go(inputs: rest, acc: acc + [result.n]) }
when Empty { go(inputs: rest, acc: acc) }
when Invalid {
IO.puts("'\(result.text)' is not a number")
go(inputs: rest, acc: acc)
}
}
}
}
func main() {
let results = go(inputs: ["3", "", "hello", "5"], acc: [])
IO.inspect(results)
}
[1, 5, 1]
Inside a when arm, the switched-on variable is narrowed to that variant's struct type, so its fields are accessible directly:
type ParseResult = Value(n: Integer) | Empty | Invalid(text: String)
func parseLine(_ s: String) -> ParseResult { Value(n: 0) }
func go(inputs: Array<Element: String>, acc: Array<Element: Integer>) -> Array<Element: Integer> { acc }
func main() {
let r = parseLine("42")
switch r {
when Value { IO.inspect(r.n) }
when Empty { IO.puts("empty") }
when Invalid { IO.puts("invalid: \(r.text)") }
}
}
0
When Arm Forms
| Pattern | Meaning |
|---|---|
when Tag | Match a constructor; switched-on variable is narrowed to that struct type inside the arm |
when A \| B | Match multiple constructors in one arm; variable narrowed to their union |
when Tag and boolExpr | Match a constructor with an additional guard condition |
when let x | Catch-all, bind to x |
when _ | Catch-all, discard value |
when "literal" | Match an exact literal value |
else { } | Catch-all arm — required when literal or expression arms are used |
Exhaustiveness
For type arms over a union type, the compiler checks structural coverage. All variants must be covered either by explicit when Tag arms or an else arm.
For literal or expression arms (e.g. when 0, when n > 5), coverage cannot be verified statically — an else arm is required. Omitting it is a compile error.
Expression Form
type Status = Ok | Error
func main() {
let status = Ok
let label = switch(status) {
when Ok { "good" }
when Error { "bad" }
}
IO.puts(label)
}
good
Error Handling
Oak uses a typed throws model. Thrown types appear in function signatures. Anything can be thrown — not just Error objects.
Throwing Functions
The throws marker is required on any function that may throw. The thrown type annotation is optional — the compiler infers it from the body, just like return types.
func myAdd(_ a: Integer, _ b: Integer) -> Integer { a + b }
type StatsError = ZeroCount
// Explicit thrown type annotation
func computeSum(from values: Array<Element: Integer>) throws StatsError -> Integer {
if Array.count(values) == 0 { throw ZeroCount }
Array.reduce(values, initial: 0, fn: myAdd)
}
func main() {
do {
let total = try computeSum(from: [1, 2, 3, 4, 5])
IO.puts("sum = \(total)")
} catch ZeroCount {
IO.puts("No numbers provided.")
}
}
sum = 15
The thrown type can be omitted from the throws annotation when it matches the declared type:
func myAdd(_ a: Integer, _ b: Integer) -> Integer { a + b }
type StatsError = ZeroCount
func computeSum(from values: Array<Element: Integer>) throws StatsError -> Integer {
if Array.count(values) == 0 { throw ZeroCount }
Array.reduce(values, initial: 0, fn: myAdd)
}
func main() {
do {
let total = try computeSum(from: [1, 2, 3])
IO.puts("sum = \(total)")
} catch ZeroCount {
IO.puts("No numbers provided.")
}
}
sum = 6
Calling Throwing Functions
type StatsError = ZeroCount
func myAdd(_ a: Integer, _ b: Integer) -> Integer { a + b }
func computeSum(from values: Array<Element: Integer>) throws StatsError -> Integer {
if Array.count(values) == 0 { throw ZeroCount }
Array.reduce(values, initial: 0, fn: myAdd)
}
func printSum(_ n: Integer) { IO.puts("sum = \(n)") }
func main() {
do {
let total = try computeSum(from: [10, 20, 30])
printSum(total) // propagate throw to caller
} catch ZeroCount {
IO.puts("empty")
}
}
sum = 60
try? — Convert throw to nil
try? evaluates the expression and returns the value on success, or nil if it throws. The type of try? expr is T? where T is the return type of the expression. No do/catch or throws annotation is needed — the throw is consumed at the call site.
type StatsError = ZeroCount
func myAdd(_ a: Integer, _ b: Integer) -> Integer { a + b }
func computeSum(from values: Array<Element: Integer>) throws StatsError -> Integer {
if Array.count(values) == 0 { throw ZeroCount }
Array.reduce(values, initial: 0, fn: myAdd)
}
func main() {
let total = try? computeSum(from: [1, 2, 3])
IO.puts(total ?? "no result")
let nothing = try? computeSum(from: [])
IO.puts(nothing ?? "no result")
}
6
no result
try! — Force-unwrap (crash on throw)
try! evaluates the expression and returns the value on success. If it throws, the program terminates immediately with a trap message. Use only when you are certain the call cannot throw.
type StatsError = ZeroCount
func myAdd(_ a: Integer, _ b: Integer) -> Integer { a + b }
func computeSum(from values: Array<Element: Integer>) throws StatsError -> Integer {
if Array.count(values) == 0 { throw ZeroCount }
Array.reduce(values, initial: 0, fn: myAdd)
}
func main() {
let total = try! computeSum(from: [1, 2, 3])
IO.puts("sum = \(total)")
}
sum = 6
do / catch
type StatsError = ZeroCount
func myAdd(_ a: Integer, _ b: Integer) -> Integer { a + b }
func computeSum(from values: Array<Element: Integer>) throws StatsError -> Integer {
if Array.count(values) == 0 { throw ZeroCount }
Array.reduce(values, initial: 0, fn: myAdd)
}
func main() {
do {
let total = try computeSum(from: [])
IO.puts("sum = \(total)")
} catch ZeroCount {
IO.puts("No numbers entered.")
} catch {
IO.puts("Unknown error.")
}
}
No numbers entered.
do is an expression — it returns the value of the block or the matching catch arm.
Catching in Expressions
type ParseResult = Value(Integer) | Invalid(String)
func main() {
let line = " 42 "
let result = do {
Value(Array.count(String.graphemes(String.trim(line))))
} catch {
Invalid(line)
}
IO.inspect(result)
}
Value(2)
Strings
Literals & Escape Sequences
func main() {
IO.puts("hello, world")
IO.puts("line one\nline two")
IO.puts("tab\there")
let name = "Alice"
IO.puts("Hello, \(name)!")
}
hello, world
line one
line two
tab here
Hello, Alice!
| Sequence | Meaning |
|---|---|
\\ | Backslash |
\" | Double quote |
\n | Newline |
\t | Tab |
\r | Carriage return |
\0 | Null byte |
\u{NNNN} | Unicode scalar (hex) |
\(expr) | Interpolated expression |
Encoding
Strings are UTF-8 internally. Use String.graphemes to iterate human-visible characters:
func main() {
IO.inspect(String.graphemes("café"))
IO.inspect(Array.count(String.graphemes("café")))
}
["c", "a", "f", "é"]
4
String.count and String.graphemes both count Unicode code points, not grapheme clusters. For most text this is the same, but complex emoji sequences (e.g. family emoji with ZWJ) may count as multiple code points. String.bytes and String.scalars are planned but not yet implemented.Collections
Arrays
func main() {
let nums = [1, 2, 3]
let names = ["Alice", "Bob"]
IO.inspect(nums[0])
IO.inspect([1, 2] + [3, 4])
IO.inspect(names)
}
1
[1, 2, 3, 4]
["Alice", "Bob"]
Spread
... spreads an array into an array literal, or a dict into a dict literal. Multiple spreads are allowed. In dict literals, later keys override earlier ones.
func main() {
let a = [1, 2, 3]
IO.inspect([0, ...a, 4]) // [0, 1, 2, 3, 4]
let defaults = { "timeout": 30, "retries": 3 }
let config = { ...defaults, "timeout": 60 }
IO.inspect(config) // {"timeout": 60, "retries": 3}
}
[0, 1, 2, 3, 4]
{"timeout": 60, "retries": 3}
Dictionaries
func main() {
let scores = { "Alice": 10, "Bob": 7 }
IO.inspect(scores["Alice"])
IO.inspect(Dict.keys(scores))
}
10
["Alice", "Bob"]
Pipeline Operator
|> passes the left-hand value as the first argument of the right-hand call. Parentheses are always required on the right-hand side.
func main() {
"hello world!" |> IO.puts()
let result = [3, 1, 2]
|> Array.sorted()
|> Array.reversed()
IO.inspect(result)
}
hello world!
[3, 2, 1]
Injecting at a different position
Use _ on the right-hand side to place the piped value somewhere other than the first argument:
func main() {
let result = 10 |> Math.pow(2, _) // Math.pow(2, 10)
IO.inspect(result)
}
1024
If _ appears more than once, the same value is substituted at each occurrence:
func main() {
IO.inspect(5 |> _ + _) // 10
}
10
Named-arg pipelines
Named arguments can be combined with _:
func main() {
let str = "hello"
str |> String.repeat(count: 3) |> IO.puts()
}
hellohellohello
_ Placeholder
_ is the pipeline hole — it is only valid in the right-hand side of a |> expression, and marks where the piped value is injected instead of the default first-argument position.
func main() {
let result = 10 |> Math.pow(2, _)
IO.inspect(result) // Math.pow(2, 10) = 1024
}
1024
If _ appears more than once in the right-hand side, the piped value is substituted at every occurrence:
func main() {
let doubled = 5 |> _ + _
IO.inspect(doubled) // 5 + 5 = 10
}
10
Without _, the piped value is always passed as the first argument:
func main() {
"hello" |> IO.puts()
let sorted = [3,1,2] |> Array.sorted()
IO.inspect(sorted)
}
hello
[1, 2, 3]
_ is only valid in the right-hand side of |>. Using it anywhere else is a parse error.Operators
| Category | Operators |
|---|---|
| Arithmetic | + - * / % ** |
| Wrapping arithmetic | &+ &- &* |
| Comparison | == != < > <= >= |
| Logical | && || ! |
| Nil coalescing | ?? |
| Pipeline | \|> |
Operators are not usable as bare function references. Use named equivalents from the Math module instead:
func main() {
IO.inspect(Math.add(a: 3, b: 4)) // ✓ named function call
IO.inspect(Math.min(a: 9, b: 2))
IO.inspect(Math.abs(-7))
}
7
2
7
Wrapping Arithmetic
&+, &-, and &* perform wrapping (non-trapping) arithmetic on sized integers. Sized integers trap on overflow by default; wrapping operators opt in to wraparound:
let a: Int8 = 120i8
let b: Int8 = 10i8
func main() {
let a = 120i8
let b = 10i8
IO.inspect(a &+ b) // wraps instead of trapping
}
-126
Chained Comparisons
Chained comparisons are valid and mean what they look like:
func main() {
let n = 5
IO.inspect(1 <= n && n <= 10)
}
True
Nil Coalescing
func main() {
let scores = { "Alice": 10, "Bob": 7 }
let value = Dict.get(scores, key: "Carol") ?? 0
IO.inspect(value)
}
0
Numeric Types
Defaults: Integer and Rational
Oak's default numeric types are mathematically exact.
Integer — arbitrary-precision whole number. No overflow, ever.
func main() {
IO.inspect(2 ** 64)
IO.inspect(2 ** 128)
}
18446744073709551616
340282366920938463463374607431768211456
Rational — exact rational arithmetic. Division never loses precision.
func main() {
let a = 1 / 3
let b = 2 / 3
IO.inspect(a + b) // exactly 1, not 0.9999...
IO.inspect(a * 6) // exactly 2
}
1
2
Literal Syntax
Plain integer and decimal literals produce Integer and Rational respectively:
func main() {
IO.inspect(42) // Integer
IO.inspect(3.14) // Rational
IO.inspect(1 / 3) // Rational — expression, not a special literal
}
42
3.14
0.3333333333
Base Prefixes
| Prefix | Base | Example |
|---|---|---|
| *(none)* | decimal | 255 |
0x | hexadecimal | 0xFF |
0o | octal | 0o17 |
0b | binary | 0b1010 |
0d | explicit decimal | 0d255 |
func main() {
IO.inspect(0xFF)
IO.inspect(0b1010)
IO.inspect(0o17)
}
255
10
15
Digit Separators
_ can appear anywhere in a numeric literal as a visual separator:
func main() {
IO.inspect(1_000_000)
IO.inspect(0xFF_EC_00_1A)
}
1000000
4293656602
Scientific Notation
Decimal literals accept an e or E exponent. The lexer scales the value at parse time:
func main() {
IO.inspect(3.5e2) // → 350 (Integer)
IO.inspect(1.5e-1) // → 0.15 (Rational)
IO.inspect(1e10) // → 10000000000
}
350
0.15
10000000000
0x hex literals, e and E are hex digits, not exponent markers.Sized / Performance Types
When interoperating with C, GPUs, or performance-critical code, opt-in to fixed-width types using a suffix:
| Suffix | Type | Width |
|---|---|---|
i8 i16 i32 i64 | Int8 … Int64 | 8–64-bit signed, wrapping |
u8 u16 u32 u64 | UInt8 … UInt64 | 8–64-bit unsigned, wrapping |
f32 | Float32 | IEEE 754 32-bit |
f64 | Float64 | IEEE 754 64-bit |
func main() {
IO.inspect(255u8)
IO.inspect(1000i32)
IO.inspect(3.14f64)
}
255
1000
3.14
Float32 and Float64 use IEEE 754 representation. 0.1 + 0.2 ≠ 0.3 in float arithmetic. Use Rational for financial, business, or general-purpose decimal math. Sized integer types wrap on overflow — use Integer when correctness matters more than size.Imports
All imports use import <what> from <source>. from is a contextual keyword — it is not reserved, so move(from: a, to: b) is still valid Oak.
Import Forms
import { Vector } from "./geometry" // selective — brings Vector into scope
import { Foo: Bar } from "./utils" // rename — Foo enters scope as Bar
import * from "./utils" // wildcard — all public declarations
import Utils from "./utils" // namespace — all public declarations under Utils.X
{ Foo } is shorthand for { Foo: Foo }. Multiple names and renames can be mixed:
import { encode, decode: parse } from "json"
Source Types
Local file — path starts with ./ or ../; always relative to the importing file. The .oak extension is always omitted:
import { Vector } from "./geometry/vector"
import { Config } from "../config"
OAKPATH — no leading ./; searches the stdlib first, then installed packages:
import { encode, decode } from "json"
import * from "http"
import Math from "math"
Git — direct dependency on a specific commit hash:
import * from git(repo: "[email protected]:example/oak-extras.git", hash: "a3f8c2e9...")
Working Example
OAKPATH imports of stdlib modules are available anywhere. Namespace imports give all functions a qualified prefix:
import Math from "math"
func main() {
IO.puts(String(Math.sqrt(144)))
IO.puts(String(Math.pow(base: 2, exp: 10)))
}
12
1024
IO module
Functions for writing to stdout and reading from stdin. Always available — no import needed.
Writes s followed by a newline to the output destination. Destination defaults to Stdout.
func main() {
IO.puts("hello")
"world" |> IO.puts()
}
hello
world
Writes s to the output destination without a trailing newline.
func main() {
IO.print("no newline here, ")
IO.puts("then newline")
}
no newline here, then newline
Reads one line from stdin, blocking until available. Returns the line with the trailing newline stripped. If a line arrived before this call and was not yet consumed, it is returned immediately.
Writes a debug representation of any value to stdout, followed by a newline.
func main() {
IO.inspect([1, 2, 3])
IO.inspect({ "key": "value" })
}
[1, 2, 3]
{"key": "value"}
Pauses execution for ms milliseconds, yielding to other concurrent fibers while waiting.
func main() {
IO.puts("before")
IO.sleep(ms: 10)
IO.puts("after")
}
before
after
String module
String functions live in the String module and take the string as an explicit first argument. The pipeline operator can be used to chain them left-to-right:
func main() {
let str = "hello"
String.puts(str)
str |> String.upcase() |> IO.puts()
}
hello
HELLO
Constructor
Converts any value to its string representation.
func main() {
IO.puts(String(42))
IO.puts(String(3.14))
IO.puts(String(true))
}
42
3.14
True
Output
Writes the string followed by a newline to stdout.
Writes the string to stdout without a trailing newline.
Inspection
Number of Unicode code points in the string. For most text this matches the number of visible characters, but complex emoji sequences may count as more than one.
func main() {
IO.inspect(String.count("café"))
}
4
Returns true if the string contains no characters.
Transformation
Returns a copy with leading and trailing whitespace removed.
func main() {
IO.puts(String.trim(" hello "))
}
hello
Returns the string with all letters converted to uppercase.
Returns the string with all letters converted to lowercase.
Returns a new string with all occurrences of from replaced by to.
func main() {
IO.puts(String.replace("aabbcc", from: "b", to: "x"))
}
aaxxcc
Splitting & Searching
Returns an array of Unicode code points as single-character strings. Use this for character-by-character iteration.
func main() {
IO.inspect(String.graphemes("café"))
IO.inspect(Array.count(String.graphemes("café")))
}
["c", "a", "f", "é"]
4
Splits the string on every occurrence of separator and returns the parts as an array.
func main() {
IO.inspect(String.split("a,b,c", separator: ","))
}
["a", "b", "c"]
Returns true if the string contains the given substring.
func main() {
IO.inspect(String.contains("hello", sub: "ell"))
}
True
Returns true if the string begins with prefix.
func main() {
IO.inspect(String.startsWith("hello", prefix: "he"))
}
True
Returns true if the string ends with suffix.
func main() {
IO.inspect(String.endsWith("hello", suffix: "lo"))
}
True
Returns the index of the first occurrence of sub, or -1 if not found.
func main() {
IO.inspect(String.indexOf("hello", sub: "ll"))
}
2
More Transformations
Returns the substring from index from (inclusive) to to (exclusive).
func main() {
IO.puts(String.slice("hello", from: 1, to: 3))
}
el
Returns the string repeated count times.
func main() {
IO.puts(String.repeat("ab", count: 3))
}
ababab
Returns the string with characters in reverse order.
Pads the string on the left to at least width characters using with.
func main() {
IO.puts(String.padLeft("7", width: 3, with: "0"))
IO.puts(String.padLeft("42", width: 5, with: " "))
}
007
42
Pads the string on the right to at least width characters using with.
Removes leading whitespace only.
Removes trailing whitespace only.
Splits the string on newlines and returns the lines as an array.
func main() {
IO.inspect(String.lines("a\nb\nc"))
}
["a", "b", "c"]
Returns the concatenation of two strings. The + operator does the same thing.
Array module
Arrays are ordered, homogeneous collections. Indexing is zero-based. All functions return new arrays — nothing mutates in place. Array functions take the array as the first argument.
Inspection
The number of elements in the array.
func main() {
IO.inspect(Array.count([1, 2, 3]))
}
3
Returns true if the array contains no elements.
Returns the first element, or nil if the array is empty.
Returns the last element, or nil if the array is empty.
Transformation
Returns a new array by applying fn to each element.
struct Person {
name: String
age: Integer
}
let people = [Person(name: "Alice", age: 30), Person(name: "Bob", age: 17)]
func double(_ n: Integer) -> Integer { n * 2 }
func getName(_ p: Person) -> String { p.name }
func main() {
IO.inspect(Array.map([1, 2, 3], double))
IO.inspect(Array.map(people, getName))
}
[2, 4, 6]
["Alice", "Bob"]
Returns a new array containing only elements for which fn returns true.
func isGt3(_ n: Integer) -> Bool { n > 3 }
func isEven(_ n: Integer) -> Bool { n % 2 == 0 }
func main() {
let values = [1, 2, 3, 4, 5, 6]
IO.inspect(Array.filter(values, isGt3))
IO.inspect(Array.filter(values, isEven))
}
[4, 5, 6]
[2, 4, 6]
Folds the array left-to-right, starting with initial.
func myAdd(_ a: Integer, _ b: Integer) -> Integer { a + b }
func myMin(_ a: Integer, _ b: Integer) -> Integer { if a < b { a } else { b } }
func main() {
let values = [4, 1, 9, 2, 7]
IO.inspect(Array.reduce(values, initial: 0, fn: myAdd))
IO.inspect(Array.reduce(values, initial: values[0], fn: myMin))
}
23
1
Returns a new array with elements in reverse order.
Returns a sorted copy. Without a comparator, uses natural ordering. With a comparator function (T, T) → Bool, sorts by that function's result.
func main() {
IO.inspect(Array.sorted([3, 1, 2]))
IO.inspect(Array.reversed(Array.sorted([3, 1, 2])))
}
[1, 2, 3]
[3, 2, 1]
_ placeholder creates a single-argument function, so it cannot be used directly as a two-argument comparator. Pass a named function reference instead.Search
Returns true if value is present in the array.
String Conversion
Joins all elements into a string, separated by separator.
func main() {
IO.puts(Array.join(["a", "b", "c"], separator: ","))
IO.puts(Array.join(["one", "two", "three"], separator: " | "))
}
a,b,c
one | two | three
Concatenation
func main() {
IO.inspect([1, 2] + [3, 4])
}
[1, 2, 3, 4]
Dict module
Dictionaries are unordered key-value collections. Keys are strings. All functions return new dicts — nothing mutates in place. Dict literals use {"key": value} syntax.
func main() {
let scores = { "Alice": 10, "Bob": 7 }
IO.inspect(scores["Alice"])
IO.inspect(Dict.count(scores))
}
10
2
Number of key-value pairs.
Returns true if the dict has no entries.
Returns the value for key, or nil if absent. Subscript syntax d["key"] does the same thing.
let scores = { "Alice": 10, "Bob": 7 }
func main() {
IO.inspect(Dict.get(scores, key: "Alice"))
IO.inspect(scores["Bob"])
IO.inspect(Dict.get(scores, key: "Carol") ?? 0)
}
10
7
0
Returns true if the key exists.
Returns a new dict with the given key set to value. The original is unchanged.
let scores = { "Alice": 10, "Bob": 7 }
func main() {
let updated = Dict.set(scores, key: "Carol", value: 9)
IO.inspect(Dict.count(updated))
IO.inspect(Dict.get(updated, key: "Carol"))
}
3
9
Returns a new dict with key removed.
Returns all keys as an array.
Returns all values as an array.
Returns all entries as an array of [key, value] pairs.
func main() {
IO.inspect(Dict.entries({ "a": 1, "b": 2 }))
}
[["a", 1], ["b", 2]]
Returns a new dict containing all entries from both dicts. Keys in b take precedence over a.
func main() {
let a = { "x": 1, "y": 2 }
let b = { "y": 99, "z": 3 }
IO.inspect(Dict.merge(a, b))
}
{"x": 1, "y": 99, "z": 3}
Builds a dict from an array of keys, computing each value by calling fn with the key.
Math module
Named equivalents for arithmetic operators, plus transcendental functions. Use these where a function reference is needed.
Named Arithmetic (for use as function references)
| Function | Equivalent to | Description |
|---|---|---|
Math.add(a, b) | a + b | Addition |
Math.sub(a, b) | a - b | Subtraction |
Math.mul(a, b) | a * b | Multiplication |
Math.div(a, b) | a / b | Division |
Math.min(a, b) | — | Lesser of two values |
Math.max(a, b) | — | Greater of two values |
Math.pow(base, exp) | a ** b | Exponentiation |
Single-argument functions
| Function | Description |
|---|---|
Math.abs(x) | Absolute value |
Math.sqrt(x) | Square root |
Math.floor(x) | Round toward negative infinity |
Math.ceil(x) | Round toward positive infinity |
Math.round(x) | Round to nearest integer |
func main() {
IO.inspect(Math.add(a: 3, b: 4))
IO.inspect(Math.min(a: 9, b: 2))
IO.inspect(Math.max(a: 9, b: 2))
IO.inspect(Math.abs(-7))
IO.inspect(Math.sqrt(16))
}
7
2
9
7
4
Rational constructor
Exact rational arithmetic. The default numeric type for non-integer values. Arithmetic operations produce exact results — no floating-point rounding.
Parses a decimal string into an exact rational number. Throws InvalidNumber if the string is not a valid number.
func main() {
// Pipeline usage: read a number, trim, then use as a rational
let s = " 3.14 "
IO.puts(String.trim(s))
}
3.14
Rational values support all arithmetic operators and comparison. Results are automatically reduced to lowest terms.
func main() {
let a = 3 / 2 // rational division
let b = 1 / 2
IO.inspect(a + b)
IO.inspect(a * b)
IO.inspect(a > b)
}
2
0.75
True
Integer constructor
Arbitrary-precision integer. The default integer type — no overflow possible. Integer literals in Oak source code produce Integer values.
Parses a string as an integer. Throws InvalidInteger if the string is not valid.
func main() {
IO.inspect(2 ** 64)
IO.inspect(2 ** 64 * 2 ** 64) // no overflow
}
18446744073709551616
340282366920938463463374607431768211456
spawn / await
Oak uses lightweight fibers for concurrency. spawn starts a fiber and immediately returns a Process value. await blocks the current fiber until the process completes and returns its value.
No async keyword, no function coloring — any function can await without marking itself or its callers.
spawn
func main() {
let p = spawn {
IO.sleep(ms: 10)
"done"
}
let result = await p
IO.puts(result)
}
done
An optional label: argument names the fiber for debugging:
func doWork() -> String { "work complete" }
func main() {
let p = spawn(label: "worker") {
doWork()
}
IO.puts(await p)
}
work complete
await
func main() {
let p = spawn { "hello from fiber" }
let result = await p
IO.puts(result)
}
hello from fiber
Await multiple processes at once by passing an array — all run concurrently:
func main() {
let p1 = spawn { IO.sleep(ms: 10) "first" }
let p2 = spawn { IO.sleep(ms: 5) "second" }
let [r1, r2] = await [p1, p2]
IO.puts(r1)
IO.puts(r2)
}
first
second
Error handling
Throws inside a spawn block surface at the await call site:
type WorkError = Failed
func riskyWork() throws WorkError -> String {
throw Failed
}
func main() {
let p = spawn { try riskyWork() }
do {
let result = await p
IO.puts(result)
} catch Failed {
IO.puts("caught")
}
}
caught
For fire-and-forget spawns, handle throws inside the block:
type TelemetryError = Timeout
func telemetry() throws TelemetryError { throw Timeout }
func main() {
let p = spawn {
do { try telemetry() } catch { } // discard errors
"done"
}
await p
IO.puts("finished")
}
finished
Example
func main() {
let p1 = spawn {
IO.sleep(ms: 50)
"result1"
}
let p2 = spawn {
IO.sleep(ms: 100)
"result2"
}
let [r1, r2] = await [p1, p2]
IO.puts(r1)
IO.puts(r2)
}
result1
result2
Channel type
Channels are typed conduits for passing values between fibers. Always available — no import needed.
Creating channels
Creates a new channel. capacity: 0 (the default) is unbuffered — a send blocks until a receiver is ready. A positive capacity allows that many values to be buffered before sends block.
func main() {
let ch = Channel.new() // unbuffered
let ch2 = Channel.new(capacity: 10) // buffered
let sender = spawn { ch2.send("hello") }
await sender
IO.puts(ch2.receive())
}
hello
Sending and receiving
Channel methods are accessed directly on the channel value:
Sends a value into the channel. Blocks if the channel is full (unbuffered: blocks until a receiver calls receive).
Receives a value from the channel. Blocks if the channel is empty.
Example
func main() {
let ch = Channel.new()
let sender = spawn {
ch.send(1)
ch.send(2)
ch.send(3)
}
IO.puts(ch.receive())
IO.puts(ch.receive())
IO.puts(ch.receive())
await sender
}
1