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 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.
// 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.
// 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 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 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 ===")// 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.
// 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 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.
// 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 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>:
// 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:
// 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:
$ lira run unbounded.li 2:12: Cannot use Add on unconstrained generic type. Add a numeric constraint or use concrete types.
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 (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.