Skip to content

Type System

Nulang uses Algorithm W (Hindley-Milner) for global type inference. You rarely need to write type annotations — the compiler infers them:

// The compiler infers: fn compose[A,B,C](f: B -> C, g: A -> B) -> A -> C
fn compose(f, g) {
fn(x) { f(g(x)) }
}

Explicit annotations are supported and encouraged for public APIs:

fn compose[A, B, C](f: B -> C, g: A -> B) -> A -> C {
fn(x: A) -> C { f(g(x)) }
}
Type Description Example
Int 48-bit signed integer (i64-tagged value representation) 42
Float 64-bit IEEE 754 float 3.14
Bool Boolean true, false
String UTF-8 string "hello"
Unit Unit value (like void) ()
Nil Nil/null nil
Never Uninhabited type (bottom)

Records are structurally typed with row polymorphism. When a function parameter has no type annotation, the record row stays open and accepts any record with the needed fields:

// Inferred parameter — accepts ANY record with 'x' and 'y' fields
fn distance(point) {
to_float(point.x * point.x + point.y * point.y)
}
// Works with extra fields
let p3d = { x: 1, y: 2, z: 3 }
perform IO.print(perform Int.to_string(distance(p3d))) // OK — extra fields are fine

An explicit annotation creates a closed record that requires an exact field count:

fn origin(): { x: Int, y: Int } = { x: 0, y: 0 }

Closed record annotations reject records with extra or missing fields. Use inferred parameters when you want row polymorphism.

Inspired by Pony, Nulang uses reference capabilities for data-race freedom:

Capability Deny Read Deny Write Sendable Description
iso Yes Yes Yes Isolated, unique reference
trn No Yes No Transitional, write-unique
ref No No No Mutable, shared-nothing
val No Yes Yes Immutable, shareable
box Yes No No Read-only
tag Yes Yes Yes Opaque, identity-only
lineariso Yes Yes Yes Linear isolated (at-most-once)

Capabilities are compile-time only and erased at runtime. There are no runtime capability checks.

// val reference: immutable and shareable (can be sent between actors)
let shared = "hello" :cap val
perform IO.print(shared)

Sum types via type declarations:

type Option[T] = Some(T) | None
type Result[T, E] = Ok(T) | Err(E)
type List[T] = Cons(T, List[T]) | Nil

Generic type parameters use [T, U, ...] syntax and are inferred at call sites.

Every function carries an effect row. Pure functions have an empty effect row. Effectful functions declare their effects:

// Pure: no effects
fn add(x: Int, y: Int) -> Int = x + y
// Effectful: performs IO
fn greet() -> Unit ! {IO} {
perform IO.print("Hello")
}

See Algebraic Effects for the full effect system.