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

PatternMeaning
when TagMatch a constructor; switched-on variable is narrowed to that struct type inside the arm
when A \| BMatch multiple constructors in one arm; variable narrowed to their union
when Tag and boolExprMatch a constructor with an additional guard condition
when let xCatch-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!
SequenceMeaning
\\Backslash
\"Double quote
\nNewline
\tTab
\rCarriage return
\0Null 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
Note: 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]
Note: _ is only valid in the right-hand side of |>. Using it anywhere else is a parse error.

Operators

CategoryOperators
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

PrefixBaseExample
*(none)*decimal255
0xhexadecimal0xFF
0ooctal0o17
0bbinary0b1010
0dexplicit decimal0d255
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
Note: In 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:

SuffixTypeWidth
i8 i16 i32 i64Int8Int648–64-bit signed, wrapping
u8 u16 u32 u64UInt8UInt648–64-bit unsigned, wrapping
f32Float32IEEE 754 32-bit
f64Float64IEEE 754 64-bit
func main() {
    IO.inspect(255u8)
    IO.inspect(1000i32)
    IO.inspect(3.14f64)
}
255
1000
3.14
Note: 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...")
Not yet implemented: Git imports are parsed but not yet resolved at runtime.

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.

func IO.puts(_ s: String, to: Stdout = Stdout) → Nil

Writes s followed by a newline to the output destination. Destination defaults to Stdout.

func main() {
    IO.puts("hello")
    "world" |> IO.puts()
}
hello
world
func IO.print(_ s: String, to: Stdout = Stdout) → Nil

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
func IO.gets() → String

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.

func IO.inspect(_ v: Any) → Nil

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"}
func IO.sleep(ms: Integer) → Nil

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

String(_ value: Any) → String

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

func String.puts(_ s: String) → Nil

Writes the string followed by a newline to stdout.

func String.print(_ s: String) → Nil

Writes the string to stdout without a trailing newline.

Inspection

func String.count(_ s: String) → Integer

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
func String.isEmpty(_ s: String) → Bool

Returns true if the string contains no characters.

Transformation

func String.trim(_ s: String) → String

Returns a copy with leading and trailing whitespace removed.

func main() {
    IO.puts(String.trim("  hello  "))
}
hello
func String.upcase(_ s: String) → String

Returns the string with all letters converted to uppercase.

func String.downcase(_ s: String) → String

Returns the string with all letters converted to lowercase.

func String.replace(_ s: String, from: String, to: String) → String

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

func String.graphemes(_ s: String) → Array<Element: String>

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
func String.split(_ s: String, separator: String) → Array<Element: String>

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"]
func String.contains(_ s: String, sub: String) → Bool

Returns true if the string contains the given substring.

func main() {
    IO.inspect(String.contains("hello", sub: "ell"))
}
True
func String.startsWith(_ s: String, prefix: String) → Bool

Returns true if the string begins with prefix.

func main() {
    IO.inspect(String.startsWith("hello", prefix: "he"))
}
True
func String.endsWith(_ s: String, suffix: String) → Bool

Returns true if the string ends with suffix.

func main() {
    IO.inspect(String.endsWith("hello", suffix: "lo"))
}
True
func String.indexOf(_ s: String, sub: String) → Integer

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

func String.slice(_ s: String, from: Integer, to: Integer) → String

Returns the substring from index from (inclusive) to to (exclusive).

func main() {
    IO.puts(String.slice("hello", from: 1, to: 3))
}
el
func String.repeat(_ s: String, count: Integer) → String

Returns the string repeated count times.

func main() {
    IO.puts(String.repeat("ab", count: 3))
}
ababab
func String.reverse(_ s: String) → String

Returns the string with characters in reverse order.

func String.padLeft(_ s: String, width: Integer, with: String) → String

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
func String.padRight(_ s: String, width: Integer, with: String) → String

Pads the string on the right to at least width characters using with.

func String.trimLeft(_ s: String) → String

Removes leading whitespace only.

func String.trimRight(_ s: String) → String

Removes trailing whitespace only.

func String.lines(_ s: String) → Array<Element: String>

Splits the string on newlines and returns the lines as an array.

func main() {
    IO.inspect(String.lines("a\nb\nc"))
}
["a", "b", "c"]
func String.concat(_ s: String, _ other: String) → String

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

func Array.count(_ arr: Array<Element: T>) → Integer

The number of elements in the array.

func main() {
    IO.inspect(Array.count([1, 2, 3]))
}
3
func Array.isEmpty(_ arr: Array<Element: T>) → Bool

Returns true if the array contains no elements.

func Array.first(_ arr: Array<Element: T>) → T?

Returns the first element, or nil if the array is empty.

func Array.last(_ arr: Array<Element: T>) → T?

Returns the last element, or nil if the array is empty.

Transformation

func Array.map(_ arr: Array<Element: T>, _ fn: (T) → U) → Array<Element: U>

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"]
func Array.filter(_ arr: Array<Element: T>, _ fn: (T) → Bool) → Array<Element: T>

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]
func Array.reduce(_ arr: Array<Element: T>, _ initial: U, _ fn: (U, T) → U) → U

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
func Array.reversed(_ arr: Array<Element: T>) → Array<Element: T>

Returns a new array with elements in reverse order.

func Array.sorted(_ arr: Array<Element: T>, _ comparator: ((T, T) → Bool)?) → Array<Element: T>

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]
Note: The _ placeholder creates a single-argument function, so it cannot be used directly as a two-argument comparator. Pass a named function reference instead.

Search

func Array.contains(_ arr: Array<Element: T>, _ value: T) → Bool

Returns true if value is present in the array.

String Conversion

func Array.join(_ arr: Array<Element: T>, _ separator: String) → String

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
func Dict.count(_ d: Dict) → Integer

Number of key-value pairs.

func Dict.isEmpty(_ d: Dict) → Bool

Returns true if the dict has no entries.

func Dict.get(_ d: Dict, key: String) → Any

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
func Dict.has(_ d: Dict, key: String) → Bool

Returns true if the key exists.

func Dict.set(_ d: Dict, key: String, value: Any) → Dict

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
func Dict.delete(_ d: Dict, key: String) → Dict

Returns a new dict with key removed.

func Dict.keys(_ d: Dict) → Array<Element: String>

Returns all keys as an array.

func Dict.values(_ d: Dict) → Array<Element: Any>

Returns all values as an array.

func Dict.entries(_ d: Dict) → Array<Element: 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]]
func Dict.merge(_ a: Dict, _ b: Dict) → Dict

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}
func Dict.fromKeys(_ keys: Array, _ fn: (String) → Any) → Dict

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)

FunctionEquivalent toDescription
Math.add(a, b)a + bAddition
Math.sub(a, b)a - bSubtraction
Math.mul(a, b)a * bMultiplication
Math.div(a, b)a / bDivision
Math.min(a, b)Lesser of two values
Math.max(a, b)Greater of two values
Math.pow(base, exp)a ** bExponentiation

Single-argument functions

FunctionDescription
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.

Rational(_ s: String) → Rational

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.

Integer(_ s: String) → Integer

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

func Channel.new(capacity: Integer = 0) → Channel

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:

ch.send(value) → Nil

Sends a value into the channel. Blocks if the channel is full (unbuffered: blocks until a receiver calls receive).

ch.receive() → Any

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

Playground

Write and run Oak code directly in the browser. Use the Try it buttons on any code example to open it here.

Code
Press Run to execute.
Oak Playground
Code
Press Run to execute.