Lazarus Language Reference

These documents describe the current Lazarus language: what it looks like, how each feature works, and how it maps to the Lua output.

Start with 01-overview.md if you are new to the language. The documents are written to be read in order, but each one also works as a standalone reference.

Contents

DocumentWhat it covers
01-overview.mdWhat Lazarus is, the three file kinds, a complete working example
02-variables-and-types.mdBindings, mut, primitive types, strings, function types
03-classes.mdClass files: fields, constructors, methods, visibility, enums
04-control-flow.mdif, while, loop, for, for-in, match, break, return
05-types-and-data.mdOption, Result, List, Map, custom enums, generics
06-objects-and-modules.mdObject files, imports, the standard library, building
07-traits.mdTrait files, inline traits, implement
08-interop.mdextern declarations, lua bodies, platform gating

Overview

Lazarus is a small, statically-typed language that compiles to a single Lua file. You get real classes, a type system that catches mistakes before runtime, and a module system that resolves imports at compile time. The output is plain Lua that runs anywhere Lua 5.1 runs, with no runtime dependencies.

The language was designed for environments where you have Lua but not much else: ComputerCraft computers, embedded scripting runtimes, game mod environments, or any place where dropping a single .lua file is simpler than managing a package ecosystem.

Three kinds of files

Every Lazarus source file ends in .laz, but the suffix before that tells the compiler what kind of thing it defines:

.class.laz defines a class. Classes are instantiable types: they have a constructor, instance fields, and instance methods. The file name (without the suffix) becomes the class name, so Counter.class.laz defines a class called Counter.

.object.laz defines an object. Objects are static namespaces with no instances. Every member is implicitly static. They are used for utility modules, math helpers, and Lua API wrappers. Str.object.laz defines Str, which you call as Str.upper(s).

.trait.laz defines a trait. Traits are contracts: a set of method signatures that a class can promise to implement. The standard library trait Iterable.trait.laz defines what it means to be iterable.

All filenames are PascalCase.

A first example

Here is a small program that creates a counter, increments it, and prints the result:

// Counter.class.laz
import std.Sys

private count: int = 0

constructor() { }

increment() {
    .count = .count + 1
}

value(): int {
    return .count
}

static main() {
    mut c = Counter()
    c.increment()
    c.increment()
    c.increment()
    Sys.print(f"count is {c.value()}")
}

Run it:

lua bin/lazarusc.lua Counter.class.laz
lua Counter.lua
# count is 3

A few things to notice:

The file name is the class name. There is no class Counter declaration anywhere.

Fields are accessed inside methods with a leading dot: .count. This is shorthand for self.count. The self keyword also exists and can be used directly, but the dot form is more common.

static main() is the entry point. The compiler appends a call to it at the end of the output. Static methods belong to the class, not to any instance.

f"..." is a format string. Expressions inside {} are evaluated and interpolated into the string. Regular "..." strings have no interpolation.

Building

The self-hosted compiler is bin/lazarusc.lua. It takes a source file and writes <ClassName>.lua to the current directory:

lua bin/lazarusc.lua MyProgram.class.laz
lua MyProgram.lua

The stdlib is not bundled. The compiler looks for std/ next to your source file. Before compiling your own projects, either copy or symlink std/ from the Lazarus repo into your project directory, or pass --pkg-path to tell the compiler where to find it:

# symlink (recommended -- stays up to date)
ln -s /path/to/lazarus/std ./std

# or pass it at compile time
lua bin/lazarusc.lua MyProgram.class.laz --pkg-path /path/to/lazarus

Flags:

--check          # report errors without writing output
--lib            # compile as a library (no entry-point call)
--platform <n>   # set the target platform name
-O0 / -O1 / -O2 / -Os  # optimization level (O0 is default)

Via make:

make selfbuild FILE=MyProgram.class.laz

How compilation works

The compiler resolves all imports, runs each file through the pipeline (lexer, parser, schematic, typechecker, optimizer, codegen), and bundles everything into one chunk. The output has no require calls and no runtime dependencies. Dependencies are emitted in the correct order so a class is always defined before it is used.

The type system is erased at codegen. The emitted Lua carries no type information and pays no runtime cost for it.

Continue to 02-variables-and-types.md for the basics of variables and the type system.

Variables and Types

Bindings

A name assignment is immutable by default. Once bound, the name cannot be reassigned:

x = 10
x = 20  // error: x is not mutable

Add mut to make it reassignable:

mut x = 10
x = 20  // fine
x += 5  // also fine: compound assignment desugars to x = x + 5

Compound assignment works for all four arithmetic operators: +=, -=, *=, /=.

Immutability is the default because most local values in practice never change. When you do want mutation, mut makes it explicit and visible.

Type annotations

Type annotations are optional on local variables. The compiler infers the type from the right-hand side:

x = 42          // int, inferred
name = "hello"  // str, inferred
flag = true     // bool, inferred

You can write the type explicitly if you want:

x: int = 42
name: str = "hello"

On method parameters and return types, annotations are the convention and are required for type checking to work. Without them, the parameter or return type is dynamic, which disables checking for that part of the signature:

static add(a: int, b: int): int {
    return a + b
}

Primitive types

Lazarus has four primitive types:

int is a whole number. float is a real number. They are distinct and do not mix: adding an int and a float without an explicit conversion is a type error. This distinction matters because a future backend will map them to Lua 5.4's real integer and float types.

n: int   = 42
x: float = 3.14

str is a string. bool is a boolean, with the literals true and false.

There is no nil in Lazarus. Absence is represented with Option<T> (see 05-types-and-data.md).

The dynamic type

dynamic is the escape hatch. A value of type dynamic bypasses type checking. It flows into any typed slot and accepts any operation. Most code should avoid it, but it is useful at the Lua boundary (see 07-interop.md) and in generic containers that the type system cannot represent yet.

x: dynamic = anything_goes()

unit

unit is the return type for functions that return nothing. It is equivalent to void in other languages. You write it explicitly in method signatures:

log(msg: str): unit {
    Sys.print(msg)
}

Function types

Functions are first-class values. The type of a function that takes an int and returns a str is written (int) -> str:

static apply(n: int, f: (int) -> int): int {
    return f(n)
}

Multiple parameters are separated by commas: (str, int) -> bool. A function that takes nothing uses empty parentheses: () -> str.

Anonymous functions use the fn keyword:

double = fn(x: int): int = x * 2
greet  = fn(name: str): str { return "hello " ++ name }

The short form fn(params): RetType = expr works for single-expression bodies. The block form fn(params): RetType { ... } works for anything else.

Strings

Regular strings use double quotes with no interpolation:

greeting = "hello, world"
path = "src/main.laz"

Supported escape sequences: \n, \t, \r, \", \\.

Format strings use the f prefix. Expressions inside {} are evaluated and embedded:

name = "al"
n    = 42
msg  = f"hello {name}, count is {n + 1}"

Multi-line strings use triple quotes. The f prefix works here too:

block = """
    line one
    line two
    """

generated = f"""
    name: {name}
    count: {n}
    """

String concatenation uses ++, not +. The + operator is numeric only:

full = first ++ " " ++ last

Summary

  • mut opts into reassignment; bindings are immutable by default.
  • Type annotations on locals are optional; on method signatures they enable type checking.
  • Primitive types: int, float, str, bool, unit, dynamic.
  • f"..." for interpolated strings, "..." for plain strings, """...""" for multi-line.
  • ++ for string concatenation.
  • Function types are written (T) -> U; anonymous functions use fn.

Classes

A .class.laz file defines a class. The file name (without the suffix) is the class name. There is no class keyword: the top level of the file is the class body.

Fields

Instance fields are declared at the top level of the file with a visibility keyword and a name. The type annotation is optional but recommended:

// Point.class.laz
public x: int
public y: int
private label: str = "point"

public makes a field accessible to other files. private keeps it internal. Fields have no default visibility: one of the two must be written. A field with a default value (= expr) uses that value if the constructor does not set it. A field without a default must be assigned in the constructor.

Static fields belong to the class itself rather than any instance:

private static count: int = 0
public static max: int = 1000

Constructor

A class has one constructor block. Instances are created by calling the class name:

constructor(x: int, y: int) {
    .x = x
    .y = y
}

p = Point(3, 4)

Inside the constructor, .field assigns an instance field. This is shorthand for self.field.

For fields that are simply assigned from a constructor parameter with the same name, there is a shorthand: prefix the parameter name with a dot. This declares the field and assigns it in one step:

constructor(.x: int, .y: int) { }

This is exactly equivalent to declaring the fields at the top and writing .x = x, .y = y in the body. If the field was already declared above with an explicit visibility, the shorthand just generates the assignment without re-declaring it.

Instance methods

Instance methods are declared with just a name, parameters, and a body. No keyword is needed:

distance(other: Point): float {
    mut dx = .x - other.x
    mut dy = .y - other.y
    return Num.sqrt((dx * dx + dy * dy) as float)
}

label(): str {
    return .label
}

Inside an instance method, .field reads or writes an instance field. The self keyword also works and is useful when you need to pass the current instance as an argument or call into a lua body.

Methods are public by default. Add private to restrict access to the class itself:

private validate(): bool {
    return .x >= 0 and .y >= 0
}

Static methods

Static methods belong to the class, not to any instance. They are declared with static:

static origin(): Point {
    return Point(0, 0)
}

Static methods are called through the class name: Point.origin(). They cannot access instance fields.

The entry point of a program is static main() in the file you build.

Nested functions

Functions can be defined inside other functions. They close over the outer scope and can call each other:

static sum_of_squares(items: List<int>): int {
    square(n: int): int {
        return n * n
    }
    mut total = 0
    for x in items {
        total = total + square(x)
    }
    return total
}

A complete example

// Counter.class.laz
import std.Sys

private count: int = 0
private step: int

constructor(step: int) {
    .step = step
}

tick() {
    .count = .count + .step
}

value(): int {
    return .count
}

reset() {
    .count = 0
}

static make(step: int): Counter {
    return Counter(step)
}

static main() {
    mut c = Counter.make(5)
    c.tick()
    c.tick()
    c.tick()
    Sys.print(f"value: {c.value()}")        // value: 15
    c.reset()
    Sys.print(f"after reset: {c.value()}")  // after reset: 0
}

Enums

Enums are declared inside a class file. They are sum types: a value of an enum type is exactly one of its variants, and each variant can carry data:

enum Direction {
    North,
    South,
    East,
    West
}

enum Shape {
    Circle(float),
    Rect(float, float),
    Dot
}

Variants without payload are bare names. Variants with payload list the types in parentheses.

Use match to branch on an enum value (see 04-control-flow.md):

static describe(d: Direction): str {
    match d {
        North => { return "north" }
        South => { return "south" }
        East  => { return "east" }
        West  => { return "west" }
    }
}

Inline traits

Traits can also be declared inside a class file. A class implements a trait by declaring implement TraitName at the top level and providing the required methods:

trait Renderable {
    draw(): str
    bounds(): Rect
}

implement Renderable

draw(): str {
    return f"point at ({.x}, {.y})"
}

bounds(): Rect {
    return Rect(.x, .y, 1, 1)
}

See 06-traits.md for the full trait system.

Control Flow

All control flow constructs are statement-based. They do not produce values. Results come from assignments inside the branches or from return.

Conditions must be bool. There is no truthiness: if x where x is an int is a type error.

if

if n < 0 {
    Sys.print("negative")
} else if n == 0 {
    Sys.print("zero")
} else {
    Sys.print("positive")
}

No parentheses around the condition. Braces are required.

while

mut i = 0
while i < 10 {
    i += 1
}

loop

loop is an explicit infinite loop. Exit with break:

loop {
    mut line = Sys.read_line().unwrap_or("")
    if line == "quit" { break }
    handle(line)
}

This reads clearer than while true when the exit condition is in the middle of the body.

for -- counting

The C-style counting loop. Each clause is separated by semicolons:

for i = 0; i < 10; i += 1 {
    Sys.print(f"step {i}")
}

The loop variable (i here) is a fresh mutable binding scoped to the loop. Any clause can be empty. The most common use is iterating over an index.

for-in -- iteration

Walk lists and maps with for...in:

names = ["alice", "bob", "carol"]
for name in names {
    Sys.print(name)
}

For maps and for lists when you need the index, use two variables:

for i, x in names {
    Sys.print(f"{i}: {x}")
}

scores = ["alice": 95, "bob": 87]
for name, score in scores {
    Sys.print(f"{name} scored {score}")
}

match

match branches on an enum value. Every variant must be covered, or you get a compile error. Use _ to match anything you do not need to handle individually:

enum Status { Active, Pending, Closed(str) }

match status {
    Active     => { Sys.print("active") }
    Pending    => { Sys.print("pending") }
    Closed(r)  => { Sys.print(f"closed: {r}") }
}

Payload variants bind their data as new variables in the arm body. There are no guards in match arms. If you need a condition, use an if inside the arm:

match result {
    Ok(n) => {
        if n > 100 {
            Sys.print("big")
        } else {
            Sys.print(f"got {n}")
        }
    }
    Err(msg) => { Sys.print(f"error: {msg}") }
}

match on Option<T> is very common:

match find_user(id) {
    Some(user) => { greet(user) }
    None       => { Sys.print("not found") }
}

break

break exits the nearest enclosing loop. There is no continue.

for x in items {
    if x < 0 { break }
    process(x)
}

To skip an item without exiting the loop, wrap the body in an if:

for x in items {
    if x > 0 {
        process(x)
    }
}

return

return exits the current function and optionally passes a value back:

classify(n: int): str {
    if n < 0  { return "negative" }
    if n == 0 { return "zero" }
    return "positive"
}

A function that returns unit can use a bare return to exit early:

log(msg: str): unit {
    if .quiet { return }
    Sys.print(msg)
}

Types and Data

Option

Option<T> represents a value that might not be there. It is an enum with two variants: Some(T) holds a value, and None means absence. There is no nil in Lazarus: whenever something might be missing, the type is Option.

find(items: List<str>, target: str): Option<int> {
    mut i = 0
    for item in items {
        if item == target { return Some(i) }
        i += 1
    }
    return None
}

The standard library provides Option as std.Option. Use match to handle both cases:

match find(names, "alice") {
    Some(i) => { Sys.print(f"found at {i}") }
    None    => { Sys.print("not found") }
}

Or use the helpers when the failure case is simple:

import std.Option

idx = find(names, "alice")

// unwrap_or returns a default when None
i = Option.unwrap_or(idx, -1)

// unwrap panics on None
i = Option.unwrap(idx)

The ? operator propagates Option out of the current function. If the value is None, the function returns None immediately. If it is Some(v), it unwraps to v:

parse_two(s: str): Option<int> {
    parts = Str.split(s, ",")
    a = parts.get(0)?
    b = parts.get(1)?
    return Some(Str.to_int(a).unwrap_or(0) + Str.to_int(b).unwrap_or(0))
}

Result

Result<T> represents either success or failure. Ok(T) holds the success value. Err(str) holds an error message:

read_config(path: str): Result<str> {
    content = Sys.read_file(path)
    match content {
        Some(s) => { return Ok(s) }
        None    => { return Err(f"cannot read {path}") }
    }
}

Use it the same way you use Option:

import std.Result

match read_config("settings.toml") {
    Ok(cfg)  => { parse(cfg) }
    Err(msg) => { Sys.print(f"error: {msg}") }
}

// or with helpers
cfg = Result.unwrap_or(read_config("settings.toml"), "")

? works on Result too. A None propagates as None; an Err propagates as Err:

load_and_parse(path: str): Result<Config> {
    raw = read_config(path)?
    return parse_config(raw)
}

Lists

A list is an ordered sequence of values with a fixed element type. The type is written List<T>:

names: List<str> = ["alice", "bob", "carol"]
nums: List<int>  = [1, 2, 3, 4, 5]
empty: List<int> = []

List indices are 0-based. The compiler adjusts them to Lua's 1-based tables automatically:

first = names[0]    // "alice"
last  = names[2]    // "carol"
names[1] = "beth"

Common operations:

names.push("dave")
popped = names.pop()             // Option<str>
found  = names.get(10)           // Option<str>, safe -- no panic on out of range
n      = names.len()
empty  = names.is_empty()
has    = names.contains("alice")

Higher-order operations:

import std.Num

squares = nums.map(fn(x: int): int = x * x)
evens   = nums.filter(fn(x: int): bool = x % 2 == 0)
total   = nums.fold(0, fn(acc: int, x: int): int = acc + x)
first3  = nums.slice(0, 3)
joined  = names.join(", ")       // "alice, bob, carol"

List comprehensions build a list from another sequence:

squares = [x * x for x in nums]
big     = [x for x in nums if x > 10]

Maps

A map is a key-value store. The type is written Map<K, V>:

scores: Map<str, int> = ["alice": 95, "bob": 87]
empty: Map<str, int>  = [:]

Access and mutation:

s = scores.get("alice")      // Option<int>
scores["carol"] = 91
scores.delete("bob")
n = scores.len()
has = scores.has("alice")

Iterating:

for name, score in scores {
    Sys.print(f"{name}: {score}")
}

Map comprehensions:

doubled = [k: v * 2 for k, v in scores]
high    = [k: v for k, v in scores if v >= 90]
keys    = scores.keys()      // List<str>
vals    = scores.values()    // List<int>

Custom enums

Enums are declared inside a class file. They can carry payload data in their variants:

enum Token {
    Number(int),
    Word(str),
    Punct(str),
    End
}

static describe(t: Token): str {
    match t {
        Number(n) => { return f"number {n}" }
        Word(w)   => { return f"word '{w}'" }
        Punct(p)  => { return f"punct '{p}'" }
        End       => { return "end" }
    }
}

Construct a variant by name:

t1 = Number(42)
t2 = Word("hello")
t3 = End

Variants are always matched exhaustively. A match that does not cover every variant is a compile error. The _ wildcard catches anything not listed:

match t {
    Number(n) => { process_number(n) }
    _         => { }
}

Generics

You can write generic functions that work over any type. Type parameters go in angle brackets after the function name:

static first<T>(items: List<T>): Option<T> {
    return items.get(0)
}

static zip<A, B>(as: List<A>, bs: List<B>): List<List<dynamic>> {
    mut result = []
    for i, a in as {
        match bs.get(i) {
            Some(b) => { result.push([a, b]) }
            None    => { }
        }
    }
    return result
}

The standard library enums Option<T> and Result<T> use generics in exactly this way.

Objects and Modules

Object files

A .object.laz file defines a static namespace. Unlike a class, it cannot be instantiated. Every member is implicitly static. Object files are used for utility functions, math helpers, and Lua API wrappers.

// MathUtils.object.laz

clamp(v: int, lo: int, hi: int): int {
    if v < lo { return lo }
    if v > hi { return hi }
    return v
}

lerp(a: float, b: float, t: float): float {
    return a + (b - a) * t
}

Call members through the module name: MathUtils.clamp(x, 0, 100).

There is no constructor, no instance fields, and no self. Everything in an object file is a function or a static value.

Imports

A file declares its dependencies with import. Paths are dot-separated and resolved from the project root (the directory you pass to the compiler):

import std.Sys
import std.Str
import std.Option
import geometry.Vec2
import geometry.shapes.Circle

The last segment of the path is the class or module name. After importing geometry.Vec2, you use it as Vec2(1, 2), Vec2.zero(), etc.

Every import is project-root-relative. There is no relative ../ form.

Building a multi-file project

The compiler takes a single entry file and follows its imports transitively. Everything reachable ends up in one output file:

lua bin/lazarusc.lua src/Main.class.laz
lua Main.lua

A typical project looks like this:

myproject/
  Main.class.laz
  engine/
    World.class.laz
    Entity.class.laz
  util/
    Grid.object.laz
  std/

Main.class.laz imports from engine.World, which imports from engine.Entity, and so on. The compiler resolves all of that, checks types, and emits one Main.lua.

The std/ directory must be present. The compiler does not ship it separately -- copy or symlink it from the Lazarus repo:

ln -s /path/to/lazarus/std ./std

Or skip the symlink and pass --pkg-path at compile time:

lua bin/lazarusc.lua src/Main.class.laz --pkg-path /path/to/lazarus

The standard library

The standard library lives in std/ and is imported with the std. prefix:

import std.Sys     // IO and process utilities
import std.Str     // String operations
import std.Num     // Math functions
import std.Path    // File path manipulation
import std.List    // List type and methods
import std.Map     // Map type and methods
import std.Option  // Option<T> enum and helpers
import std.Result  // Result<T> enum and helpers
import std.File    // File handle
import std.Json    // JSON parsing and emission
import std.Time    // Clock and time
import std.Task    // Async task utilities

Most programs import at least std.Sys for printing and std.Str for string operations.

Externs in object files

The standard library objects are implemented with extern declarations, which bind a Lazarus name to an existing Lua global:

// std/Sys.object.laz
extern print(s): unit = "print"
extern read_line(): Option<str> = "io.read"
extern exit(code: int): unit = "os.exit"

After import std.Sys, you call these as Sys.print("hello"). The type annotation on the extern declaration is what the type checker validates against. The emitted Lua just calls the Lua global directly.

See 07-interop.md for the full interop system.

Library compilation

If you are building a reusable module rather than a runnable program, compile with --lib:

lua bin/lazarusc.lua MyLib.class.laz --lib

Library mode skips the entry-point call and prepends a LAZARUS_META header to the output. The linker can then load this prebuilt .lua without re-compiling the source.

Traits

A trait defines a contract: a set of method signatures that a class can promise to implement. Traits are checked at compile time and cost nothing at runtime.

Defining a trait

Traits can be defined in two places: in a dedicated .trait.laz file, or inline inside a class file with the trait keyword.

A .trait.laz file starts with an optional type parameter, then lists method signatures:

// Serializable.trait.laz
serialize(): str
deserialize(s: str): dynamic

With a type parameter:

// Container.trait.laz
<T>
get(i: int): Option<T>
len(): int
is_empty(): bool

The same trait defined inline:

// inside any .class.laz or .object.laz file
trait Serializable {
    serialize(): str
    deserialize(s: str): dynamic
}

Implementing a trait

A class declares that it implements a trait with implement TraitName. The compiler then checks that the class provides every method the trait requires, with matching signatures.

// Config.class.laz
import std.Str
implement Serializable

private data: Map<str, str>

constructor() {
    .data = [:]
}

serialize(): str {
    mut out = ""
    for k, v in .data {
        out = out ++ k ++ "=" ++ v ++ "\n"
    }
    return out
}

deserialize(s: str): dynamic {
    // simplified: returns a new Config from a serialized string
    mut cfg = Config()
    for line in Str.split(s, "\n") {
        mut parts = Str.split(line, "=")
        match parts.get(0) {
            Some(k) => {
                match parts.get(1) {
                    Some(v) => { cfg.data[k] = v }
                    None    => { }
                }
            }
            None => { }
        }
    }
    return cfg
}

If the class is missing any required method, or if a method's signature does not match, the compiler reports an error.

Trait method signatures

A trait method signature names the method, its parameters, and the return type. No body. Parameters are typed the same way as regular method parameters:

trait Drawable {
    draw(canvas: Canvas): unit
    bounds(): Rect
    visible(): bool
}

Property requirements (requiring a field of a given type) are also supported:

trait Named {
    name: str
}

A practical example

Here is a small example using a trait to define a common interface for different logging backends:

// Logger.trait.laz
log(level: str, msg: str): unit
flush(): unit
// ConsoleLogger.class.laz
import std.Sys
implement Logger

log(level: str, msg: str): unit {
    Sys.print(f"[{level}] {msg}")
}

flush(): unit { }

constructor() { }
// FileLogger.class.laz
import std.File
implement Logger

private handle: dynamic

constructor(path: str) {
    .handle = File.open(path, "w")
}

log(level: str, msg: str): unit {
    .handle.write(f"[{level}] {msg}\n")
}

flush(): unit {
    .handle.close()
}

Both ConsoleLogger and FileLogger satisfy the Logger trait. The compiler checks this at build time, so you never get a runtime failure from a missing method.

Lua Interop

Lazarus compiles to Lua and can call any Lua function or global. Two mechanisms handle this: typed extern declarations for safe, checked calls, and inline lua bodies for everything else.

extern declarations

An extern declaration binds a Lazarus name to a Lua global and gives it a type signature. After the declaration, calls through that name are type-checked and emit directly to the Lua global:

// In a .object.laz file
extern print(s: str): unit = "print"
extern read_line(): Option<str> = "io.read"
extern exit(code: int): unit = "os.exit"

The string on the right is the Lua expression to emit. It can be a global function name, a dotted member, or anything Lua evaluates as a callable:

extern floor(x: float): Option<int>  = "math.floor"
extern upper(s: str): Option<str>    = "string.upper"
extern format(fmt) = "string.format"

extern declarations without a return type, or with an Option<T> return type, wrap the Lua return value at the boundary. If Lua returns nil, the Lazarus side receives None.

You can leave a parameter untyped when the underlying Lua function is variadic or accepts multiple numeric types:

extern max(a, b) = "math.max"    // accepts int or float

Externs are declarations only: no Lua code is emitted for the declaration itself. Calls to the name emit the Lua global call directly.

lua bodies

For cases that cannot be expressed as a simple extern binding, write the Lua implementation inline with a lua body:

private lua read_bytes(n: int): Option<str> {
    local s = io.read(n)
    if s == nil then return 'None' end
    return { kind = 'Some', _1 = s }
}

A lua body is raw Lua code. It has access to the method's parameters by name, and inside a class method it has self as the instance. The return type annotation tells the type checker what to expect, but the Lua code is not checked.

The typical pattern is to write a private lua helper that wraps a Lua API, then expose it through a typed extern that routes to the helper:

private lua __lz_str_find(s, sub): int {
    return (string.find(s, sub, 1, true))
}

extern find(s: str, sub: str): Option<int> = "Str.__lz_str_find"

lua bodies can appear on instance methods, static methods, and private helpers. Add static or private in front of lua the same way you would for a regular method:

public lua push(v: T): unit {
    self.items[#self.items + 1] = v
}

private static lua from_raw(raw): List<dynamic> {
    local self = {}
    self.items = raw
    return self
}

Platform gating

Some Lua environments expose different globals. The platform modifier restricts a declaration to a specific runtime:

platform(cc)     extern sleep(n: float): unit = "os.sleep"
platform(lua54)  extern sleep(n: float): unit = "os.sleep"
platform(roblox) extern sleep(n: float): unit = "task.wait"

Pass the platform name at build time with --platform:

lua bin/lazarusc.lua Main.class.laz --platform cc

The compiler emits only the declarations that match the given platform. Declarations without a platform modifier are always included.

A practical example

Here is a small terminal utility object that wraps some ANSI escape codes:

// Terminal.object.laz
import std.Str

private lua __lz_move(row, col): unit {
    io.write("\27[" .. row .. ";" .. col .. "H")
}

private lua __lz_clear_screen(): unit {
    io.write("\27[2J\27[H")
}

extern write(s: str): unit = "io.write"

clear(): unit {
    Terminal.__lz_clear_screen()
}

move_to(row: int, col: int): unit {
    Terminal.__lz_move(row, col)
}

center(s: str, width: int): str {
    mut n   = Str.len(s).unwrap_or(0)
    mut pad = (width - n) / 2
    return Str.rep(" ", pad).unwrap_or("") ++ s
}

Usage:

import Terminal
import std.Sys

static main() {
    Terminal.clear()
    Terminal.move_to(5, 10)
    Terminal.write(Terminal.center("hello", 80))
}

The Lua calls are type-checked through the extern boundary and the type annotations on lua bodies. The Lua code itself is not checked: that is the tradeoff at the interop boundary. A wrong signature in an extern is a runtime bug, not a compile error.