Types

Types & Generics

Lira checks your types ahead of time, then erases them to run on a compact, tagged VM. Strong guarantees at compile time; one uniform value model at runtime.

Lira is statically checked. The checker verifies your program before any bytecode is generated — argument types, return types, optional handling, and generic constraints are all settled up front, and mistakes come back as located diagnostics with a line:column. What follows is a tour of the type system as it works today.

Primitives and sized integers

The everyday primitives are int, float, bool, and string. When you need a specific width, Lira offers sized integer types — signed (int8, int16, int32, int64) and unsigned (uint8, uint16, uint32), with byte as an alias for uint8. A plain int is 64-bit signed.

integer_types.li
// Integer Types Example
// Demonstrates sized integer type annotations

// Signed integers
let a: int8 = 127
let b: int16 = 32767
let c: int32 = 2147483647
let d: int64 = 9223372036854775807

// Unsigned integers
let e: uint8 = 255
let f: uint16 = 65535
let g: uint32 = 4294967295
let h: byte = 128  // byte is alias for uint8

// Default int (64-bit signed)
let i: int = 42

// Type inference still works
let j = 100  // inferred as int

println("Signed integers:")
println(a)
println(b)
println(c)

println("Unsigned integers:")
println(e)
println(f)

println("Default and inferred:")
println(i)
println(j)

// Arithmetic works across integer types
let sum = a + e  // int8 + uint8
println("Sum of int8 and uint8:")
println(sum)

Inference vs. annotations

Local let bindings infer their type from the initializer, so you rarely annotate them. Where there is nothing to infer from — function parameters and return types — annotations are required. That split keeps bodies terse while making signatures self-documenting.

inference.li
// Local `let` bindings infer their type from the initializer.
fn main() {
    let count = 42          // inferred int
    let name = "Lira"       // inferred string
    let ratio = 0.5         // inferred float
    let ready = true        // inferred bool

    println(count)
    println(name)
    println(ratio)
    println(ready)

    // String interpolation reads the bindings back.
    println("count=${count} name=${name} ready=${ready}")

    // Annotations are required where there is nothing to infer from:
    // function parameters and return types.
    println(area(3, 4))
}

// Parameters and the return type are annotated explicitly.
fn area(w: int, h: int) -> int {
    return w * h
}

Function types and closures

Functions are values. A parameter typed fn(int) -> int accepts any function with that shape, so you can pass behavior around and compose it. Closures are written |params| body and capture the variables in scope where they are defined.

function_types.li
// Test function types as parameters and return values
// @expect: 10
// @expect: 25
// @expect: 15
// @expect: 50

// Function that takes a function as parameter
fn apply(f: fn(int) -> int, x: int) -> int {
    return f(x)
}

// Function that takes two functions
fn compose(f: fn(int) -> int, g: fn(int) -> int, x: int) -> int {
    return f(g(x))
}

// Functions to pass around
fn double(x: int) -> int {
    return x * 2
}

fn square(x: int) -> int {
    return x * x
}

fn add_five(x: int) -> int {
    return x + 5
}

fn main() {
    // Apply a function
    println(apply(double, 5))  // 10

    // Apply another function
    println(apply(square, 5))  // 25

    // Compose functions: add_five(double(5)) = add_five(10) = 15
    println(compose(add_five, double, 5))

    // Compose functions: double(square(5)) = double(25) = 50
    println(compose(double, square, 5))
}

main()

A closure body can be a single expression or a full block with an early return. Annotate the closure's parameters so the checker can type the body:

closures.li
// Closures are written |params| body. They capture surrounding variables.
fn apply(f: fn(int) -> int, x: int) -> int {
    return f(x)
}

fn main() {
    let factor = 3
    let scale = |x: int| x * factor      // captures `factor`
    println(scale(10))                   // 30

    let inc = |n: int| n + 1
    println(apply(inc, 41))              // 42

    // A closure can have a full body block with an early return.
    let clamp_low = |n: int| {
        if n < 0 { return 0 }
        return n
    }
    println(clamp_low(-5))               // 0
    println(clamp_low(7))                // 7
}

main()

Arrays and tuples

An array of T is written [T]; every element shares one type. Tuples group a fixed number of values of possibly different types — (int, string) — and you take them apart by pattern matching.

array_types.li
// Array Type Annotation Tests
// Tests [T] syntax in function parameters

println("=== Array Type Parameters ===")

fn sum_array(arr: [int]) -> int {
    var total = 0
    for item in arr {
        total = total + item
    }
    return total
}

let nums = [1, 2, 3, 4, 5]
let s = sum_array(nums)
println("Sum: " + s)

fn count_items(arr: [string]) -> int {
    return len(arr)
}

let words = ["a", "b", "c"]
println("Count: " + count_items(words))

println("=== Done ===")
tuple_types.li
// Test tuple types and expressions
// @expect: 1
// @expect: hello
// @expect: 3

fn get_first(t: (int, string)) -> int {
    return match t {
        (a, b) => a
    }
}

fn get_second(t: (int, string)) -> string {
    return match t {
        (a, b) => b
    }
}

fn sum_triple(t: (int, int, int)) -> int {
    return match t {
        (a, b, c) => a + b + c
    }
}

fn main() {
    // Simple tuple
    let pair = (1, "hello")
    println(get_first(pair))
    println(get_second(pair))

    // Triple tuple
    let triple = (1, 1, 1)
    println(sum_triple(triple))
}

main()

Optionals

An optional type is written T? — it holds either a T or null. Two operators make optionals pleasant to work with: ?? supplies a fallback when the left side is null, and ?. reads a field only when the receiver is non-null, otherwise short-circuiting to null.

optional_chaining.li
// Test optional chaining (?.) and null coalesce (??)
// @expect: 42
// @expect: 0
// @expect: default

fn get_value() -> int? {
    return 42
}

fn get_null() -> int? {
    return null
}

fn get_string() -> string? {
    return null
}

fn main() {
    // Null coalesce with value
    let a = get_value() ?? 0
    println(a)  // 42

    // Null coalesce with null
    let b = get_null() ?? 0
    println(b)  // 0

    // Null coalesce with string
    let s = get_string() ?? "default"
    println(s)  // default
}

main()

Optional field access with ?. reaches into a struct safely: a present value yields the field, a null receiver yields null.

optional_access.li
// Optional Access Tests
// Tests null-safe field access with ?.
// @expect-contains: name from valid: Alice
// @expect-contains: name from null: null

println("=== Optional Access (?.) ===")

struct Person {
    name: string,
    age: int
}

let p = Person { name: "Alice", age: 30 }
println("name from valid: " + p?.name)

// Use null directly with optional access
let empty = null
println("name from null: " + empty?.name)

println("=== All Optional Access Tests Passed ===")

Result and the ? operator

Lira models fallible operations as values, not exceptions. A function that can fail returns Result<T, E>Result::Ok(value) or Result::Err(error). The postfix ? operator propagates an Err to the caller and unwraps an Ok, so success paths read top to bottom without nesting.

result_propagation.li
// Test Result type with ? operator
// @expect: 100
// @expect: error: division by zero

fn divide(a: int, b: int) -> Result<int, string> {
    if b == 0 {
        return Result::Err("division by zero")
    }
    return Result::Ok(a / b)
}

fn calculate(x: int, y: int) -> Result<int, string> {
    let result = divide(x, y)?
    return Result::Ok(result * 10)
}

fn main() {
    // Test successful case
    let r1 = calculate(100, 10)
    match r1 {
        Result::Ok(v) => println(v)
        Result::Err(e) => println("error: " + e)
    }

    // Test error propagation
    let r2 = calculate(100, 0)
    match r2 {
        Result::Ok(v) => println(v)
        Result::Err(e) => println("error: " + e)
    }
}

main()

Generics

Generic type parameters let one definition serve many concrete types. Lira supports generic functions and generic structs out of the box:

generics_basic.li
// Generics Tests
// Tests generic function and struct syntax parsing
// @expect-contains: identity int: 42
// @expect-contains: identity string: hello
// @expect-contains: box value: 100

println("=== Generic Functions ===")

// Generic identity function
fn identity<T>(x: T) -> T {
    return x
}

println("identity int: " + identity(42))
println("identity string: " + identity("hello"))

println("=== Generic Structs ===")

// Generic Box struct
struct Box<T> {
    value: T
}

let int_box = Box { value: 100 }
println("box value: " + int_box.value)

println("=== All Generics Tests Passed ===")

Generic enums work too — which means you can build your own Option- or Result-shaped types. Multiple type parameters are fine (Pair<A, B>), and you can annotate a binding with a concrete instantiation like Opt<int>:

generic_enum.li
// Test generic enum declarations (type erasure model)
// @expect: 42
// @expect: none
// @expect: both 1 hello
// @expect: neither
// @expect: 5

enum Opt<T> {
    Some(T),
    None
}

enum Pair<A, B> {
    Both(A, B),
    Neither
}

fn main() {
    // Single-param generic enum
    let some_val = Opt::Some(42)
    let none_val = Opt::None

    match some_val {
        Opt::Some(x) => println(x)
        Opt::None => println("none")
    }

    match none_val {
        Opt::Some(x) => println(x)
        Opt::None => println("none")
    }

    // Two-param generic enum
    let both = Pair::Both(1, "hello")
    let neither = Pair::Neither

    match both {
        Pair::Both(a, b) => println("both " + a + " " + b)
        Pair::Neither => println("neither")
    }

    match neither {
        Pair::Both(a, b) => println("both " + a + " " + b)
        Pair::Neither => println("neither")
    }

    // Generic enum with concrete annotation
    let o: Opt<int> = Opt::Some(5)
    match o {
        Opt::Some(n) => println(n)
        Opt::None => println("none")
    }
}

main()

Bounded-operation generics

An unconstrained parameter T is opaque — the checker has no grounds to assume it supports any particular operation. To use an operator inside a generic body, bound the parameter. A Numeric bound is exactly what licenses arithmetic on T:

bounded_generics.li
// A bounded type parameter: T must satisfy the Numeric constraint, which is
// what lets the body use the `+` operator. The same function serves ints and
// floats — the constraint is checked statically, then erased at runtime.
fn add<T: Numeric>(a: T, b: T) -> T {
    return a + b
}

fn main() {
    println(add(2, 3))        // 5
    println(add(1.5, 2.5))    // 4
}

main()

Drop the bound and the same code is rejected — using + on an unconstrained T is a compile error, with a message that tells you exactly what to add:

Checked, then erased

It's worth being precise about what generics buy you. Lira checks types statically and then performs runtime type erasure: there is no monomorphization. Every value runs on the same tagged dynamic VM, where types are carried as runtime tags rather than compiled into specialized code paths.

So generics here are a checking and ergonomics feature — they let you write one well-typed definition and have the compiler verify each use — not a performance specialization mechanism. The same erasure is why a function with untyped parameters falls back to a dynamic Any value, dispatched at runtime:

untyped_function_ops.li
// Untyped (no-annotation) function params and returns are dynamic (Any):
// operators and concrete returns work, dispatched at runtime.
// @expect: 5
// @expect: 10
// @expect: pos
// @expect: hi!
// @expect: 6

fn add(a, b) { return a + b }
fn double(x) { return x * 2 }
fn pos(x) { if x > 0 { return "pos" } return "neg" }
fn label(x) { return x + "!" }
fn name() { return "Bob" }

println(add(2, 3))
println(double(5))
println(pos(7))
println(label("hi"))
println(len(name()) + 3)

Where to go next

Optionals and Result are most useful alongside pattern matching — head to Pattern Matching for exhaustive match, guards, and tuple patterns. To put typed channels to work, see Concurrency. Or start from the top with the Guide.