Variables
Saule follows Lua’s scoping model with one deliberate departure: local makes a binding lexically scoped (visible only inside the block / function / file where it was declared), and there are no implicit globals — assigning to a name that was never declared is an error, not a new binding. To publish a name beyond its file, declare it with export. The type annotation is optional in either form — when omitted, the type is inferred from the initializer.
Local (Recommended)
Section titled “Local (Recommended)”local is the workhorse — same lifetime as the surrounding block, no leak into the rest of the program:
local name: string = "Arthur"local health: integer = 100local speed: float = 1.5local alive: boolean = trueModule Variables
Section titled “Module Variables”export name: T = value declares a variable at module scope — the file-level counterpart of a class’s public field. It is visible to every function in the file and importable by name from other modules:
-- config.sauexport appName: string = "MyGame"export version = 1 -- inferred integer
export fn showHeader() print(appName .. " v" .. version) -- visible file-wideend-- main.saufrom "config" import appName, showHeader
class Main static fn main() showHeader() -- MyGame v1 println(appName) -- MyGame endendModule variables are mutable, and every write is checked against the declared type:
export version: integer = 1
version = version + 1 -- okversion = "two" -- ERROR: `string` into `integer`Use them sparingly — mutable state reachable from anywhere is the usual source of “where did this value come from?” bugs. A name with no export stays private to its file; write those as ordinary locals.
There is no implicit-global form. Assigning to a name that was never declared is an error, so a misspelled target is reported instead of silently creating a second variable:
apName = "MyGame" -- ERROR: cannot assign to undeclared variableInferred Type
Section titled “Inferred Type”When the right-hand side is unambiguous, drop the : T:
local name = "Arthur" -- inferred stringlocal health = 100 -- inferred integerlocal speed = 1.5 -- inferred floatlocal alive = true -- inferred booleanThe explicit form is preferred for public APIs (function bodies, module-level constants, anything someone else will read); inferred bindings are fine for short-lived intermediates.
Multiple Bindings
Section titled “Multiple Bindings”Declare and assign several names in one statement. Types can be mixed (each name carries its own optional annotation):
fn divmod(a: integer, b: integer) -> (integer, integer) return a / b, a % bend
local x: integer, y: integer = 10, 20local name, age = "Arthur", 36 -- both inferredlocal q, r = divmod(17, 5) -- unpack multi-return
println(x + y) -- 30println(name .. " is " .. age) -- Arthur is 36println(q .. " remainder " .. r) -- 3 remainder 2Nullable Without Initializer
Section titled “Nullable Without Initializer”A local declaration with no initializer is implicitly nil, so the type must be nullable:
local pending: string? = nil -- oklocal pending: string? -- ok, same thinglocal pending: string -- ERROR: `string` is never nilThe same applies to any name a multiple binding leaves without a value, and to a module variable:
local host: string, port: integer = "localhost" -- ERROR: `port` is nilexport appName: string -- ERROR: never initializedReassignment
Section titled “Reassignment”local introduces the binding once; subsequent writes use plain name = expr (no local):
local hp: integer = 100hp = hp - 25 -- reassign the locallocal hp: integer = 0 -- allowed: a second `local` shadows the firstprint(hp) -- 0, the later bindingClass fields are a separate thing — they live on instances or the class itself, not in the surrounding scope. They use
name: T = exprfor public andlocal name: T = exprfor private. See Classes → Access Modifiers.
Compound Assignment
Section titled “Compound Assignment”target op= value reads the target, applies op, and writes the result back.
There is one form per arithmetic operator, plus ..= for concatenation and
&= |= <<= >>= for the bitwise ones:
local hp: integer = 100hp -= 25 -- same as `hp = hp - 25`hp *= 2hp %= 7
local scale: float = 1.0scale /= 4.0
local label: string = "level "label ..= "3" -- "level 3"
local charge: integer = 2charge ^= 10 -- 1024
local flags: integer = 0b0001flags |= 0b0100 -- 5flags <<= 2 -- 20Bitwise xor is the one operator with no compound form: ~= is how Lua spells
“not equal”, so reading it as a = a ~ b would turn a habitual comparison into
a silent mutation. It is a syntax error instead — write a = a ~ b.
The right-hand side is a full expression, so it is combined before the
operator applies — p *= 3 + 4 multiplies by 7, not by 3:
local p: integer = 2p *= 3 + 4 -- 14Any assignable target works — locals, module variables, table elements, instance fields, and statics:
local scores: table<integer> = {10, 20}scores[2] += 5 -- 25
class Counter n: integer static total: integer = 0
fn init() self.n = 0 end
fn bump() self.n += 1 Counter.total += 1 endendThe target is evaluated exactly once, so a side-effecting subscript or
receiver runs a single time — queue[next()] += 1 calls next() once, not
twice.
Typing follows target = target op value exactly: the operator’s own operand
rules apply, and the result has to fit the target’s declared type. Both of
these are compile errors:
local n: integer = 1n /= 2.0 -- ERROR: cannot mix integer and floatlocal n: integer = 1n ..= "x" -- ERROR: `..` yields a string, `n` is an integerCompound assignment is a statement, not an expression — local x = (y += 1)
does not parse. It also routes through operator overloads, so a class that
implements OpAdd supports += with no extra work:
class Vec implements OpAdd<Vec, Vec> x: integer = 0 y: integer = 0
fn init(x: integer, y: integer) self.x = x self.y = y end
fn add(other: Vec) -> Vec return Vec(self.x + other.x, self.y + other.y) endend
local v: Vec = Vec(1, 2)v += Vec(10, 20) -- calls Vec.addprintln(v.x .. "," .. v.y) -- 11,22There is deliberately no compound form for the comparison or logical operators:
and= / or= would have to answer whether the right-hand side is evaluated
when the operator short-circuits.