Skip to content
Saule
Docs

Tables

Tables are Saule’s only data structure — same model as Lua. A single table holds both an array part (contiguous 1-based integer keys) and a map part (everything else: strings, booleans, non-positive integers). The two parts share one value space and one length.

A table type is written in one of two forms:

Form Meaning
table<V> Array: integer keys, V values
table<K, V> Map: K keys (integer or string), V values
table Any table — element types unknown

table<V> and table<integer, V> are the same type; the array form just leaves the implicit integer key unwritten.

local names: table<string> = {"alice", "bob"} -- array of string
local same: table<integer, string> = names -- identical type
local ages: table<string, integer> = {alice: 30} -- string-keyed map
local nested: table<table<string>> = {names} -- tables nest

T[] is sugar for table<integer, T> — the integer-keyed array form, written the way most languages write an array:

local names: string[] = {"alice", "bob"}
local same: table<integer, string> = names -- the same type, spelled out
local also: table<string> = names -- and the same as the array form

It is only notation. There is no separate array type: string[], table<integer, string> and table<string> are one type with three spellings, so they are mutually assignable, and all three print as table<integer, string> when a message reports a type back.

Each suffix wraps the type once, so they stack:

Written Means
T[] table<integer, T>
T[][] table<integer, table<integer, T>>
T[,] table<integer, table<integer, T>> — a comma is another dimension
T[,,] three levels, the same as T[][][]

T[,] and T[][] are the same type. Saule’s tables nest rather than being rectangular, so there is nothing for a comma to mean other than another level, and no dimension is checked: a T[,] can hold rows of different lengths.

local grid: integer[][] = {{1, 2}, {3, 4}}
local same: integer[,] = grid -- identical type
println(grid[2][1]) -- 3

Suffixes apply left to right, which is what makes ? read correctly beside one:

class Dog
name: string = "rex"
end
local maybeList: Dog[]? = nil -- a nullable array
local maybeDogs: Dog?[] = {Dog(), nil} -- an array of nullable Dog
println(maybeList == nil) -- true
println(#maybeDogs) -- 2

There is no [K] form for map keys — a string-keyed table stays table<string, V>. A suffix only ever adds an integer-keyed level, because that is the one case with an implicit key to leave out.

Tables are mutable, so a table<Dog> is not a table<Animal> — in either direction. Writing through the wider name would put an Animal into a table the narrower name still believes holds only Dogs:

class Animal
fn speak() -> string
return "..."
end
end
class Dog extends Animal
fn fetch() -> string
return "ball"
end
end
local dogs: table<Dog> = {}
local animals: table<Animal> = dogs -- ERROR: table<Dog> is not table<Animal>
Table.insert(animals, Animal()) -- ...this is why

The same applies to key types: table<string, integer> and table<integer, integer> are unrelated.

An empty {} literal has no element type yet, so it fills any table slot, and a bare table annotation accepts anything.

-- Array part (positional entries — auto-indexed from 1).
local nums: table<integer> = {10, 20, 30}
print(nums[1]) -- 10
print(#nums) -- 3
-- Map part (named entries).
local p: table = { name: "Arthur", health: 100, alive: true }
-- Mixed: positional first, then named.
local mix: table = { "a", "b", color: "red", 99 }

Keys in { key: value } literals can be bare identifiers or quoted strings — both produce a string-keyed map entry.

A key in brackets is an expression, evaluated where it is written:

local field: string = "name"
local row: table<string, string> = { [field]: "Ada", role: "author" }
-- Any key type the table accepts, not only strings:
local byId: table<integer, string> = { [1 + 1]: "two", [3]: "three" }

The brackets are what distinguishes the two meanings: a bare name: is sugar for the string key "name", so a key that should be computed has to say so — { field: "Ada" } keys by the text "field", { [field]: "Ada" } by what field holds. ["name"] and name: are the same key written two ways, and a computed key is checked against the table’s declared key type exactly as t[k] = v would be.

t[k] accepts any value as a key:

local scores: table = {}
scores["arthur"] = 50
scores["merlin"] = 80
scores[1] = "first place"

t.foo is equivalent to t["foo"] — both read and write, in any combination:

local cfg: table = {}
cfg.title = "My Game" -- same as cfg["title"] = "My Game"
cfg["width"] = 1920 -- same as cfg.width = 1920
print(cfg.title) -- "My Game"
print(cfg["width"]) -- 1920
print(cfg.missing) -- nil (no error — missing keys yield nil)

This is plain map sugar, so it only applies to tables. Class instances and statics keep their strict obj.field semantics: writing a previously-undeclared field on an instance is still a compile error.

#t returns the array length — the count of contiguous integer keys starting at 1. Map entries don’t contribute to #:

local t: table = {10, 20, 30, name: "tags"}
print(#t) -- 3

Assigning nil clears a map entry’s value but does not remove its key — so JSON-style {"x": null} round-trips faithfully:

local user: table<string, any> = {}
user["name"] = "Arthur"
user["draft"] = true
user["draft"] = nil
print(user["draft"] ?? "no value") -- no value
print(#Table.keys(user)) -- 2: the key is still there

There is no function that drops a single map key. Table.remove(t, i) takes an integer position in the array part and shifts the elements after it down, which is a different operation; Table.clear(t) empties the whole table. To build a map without a key, leave it out rather than setting it and removing it.

for v in t walks the array part. for k, v in t walks both the array and map parts (key/value iteration). See Loops and the Standard Library for the full set of helpers (Table.insert, Table.sort, Table.concat, …).