Table lookup and mutation
A table stores associations between keys and values. You choose one key type and one value type, and the compiler checks every lookup and mutation against that choice.
local scores: {[String]: Int} = { ada = 10, grace = 20,}
scores["linus"] = 15scores["ada"] = 11
local ada: Int? = scores["ada"]local missing: Int? = scores["unknown"]The first assignment inserts a new entry. The second replaces the value associated with an existing key. Both lookups return Int?, including the lookup for "ada": the type checker does not prove that a runtime key is present merely because the source contains a matching table field.
This chapter develops that example from the source syntax down to the current rc.3 runtime representation.
The table mental model
Section titled “The table mental model”Think of a table as a collection of key-value pairs:
"ada" -> 11"grace" -> 20"linus" -> 15Each key identifies at most one value. Assigning a value to a key that is not present inserts a pair. Assigning to a key that is already present changes that pair’s value.
A table is different from an array:
- an array is indexed by a position from
0tolength - 1; - a table is indexed by a supported key value;
- an array lookup has bounds to check;
- a missing table key produces
nilthrough an optional result.
Tables are also different from records. A record has a fixed set of fields known from its declaration. A table may acquire new keys while the program runs.
Reading a table type
Section titled “Reading a table type”The table type syntax is:
{[K]: V}K is the key type and V is the value type. For example:
local namesById: {[Int]: String} = {}local enabledByName: {[String]: Boolean} = {}local bytesByFlag: {[Boolean]: UInt8} = {}The named form expresses the same type:
local namesById: Table<Int, String> = {}The brace form is usually easier to recognize beside a table literal. The named form can be useful inside a longer type.
Table types are invariant. The key and value types must match exactly; the compiler does not reinterpret one table type as another.
local narrow: {[String]: Int32} = {}
-- This is not an implicit widening conversion.-- local wide: {[String]: Int64} = narrowThat rule protects mutation. If wide could refer to the same table as narrow, code using wide could store an Int64 that cannot be read safely through the Int32 view.
Constructing a table
Section titled “Constructing a table”An empty aggregate needs an expected table type:
local scores: {[String]: Int} = {}Without the annotation, {} does not say whether it is a record, table, or another aggregate shape. Pop does not guess from later statements.
For a table with String keys, named fields are convenient literal syntax:
local ports: {[String]: UInt16} = { http = 80, https = 443,}In a table context, http = 80 creates the same association that later bracket operations address as ports["http"].
For other key types, begin with an empty typed table and insert entries explicitly:
local names: {[Int]: String} = {}
names[1] = "Ada"names[2] = "Grace"This makes the key expression and its exact type visible.
Supported key types
Section titled “Supported key types”rc.3 accepts a deliberately closed set of table key types:
| Key family | Examples |
|---|---|
| Boolean | Boolean |
| Signed integers | Int8, Int16, Int32, Int64, Int |
| Unsigned integers | UInt8, UInt16, UInt32, UInt64, Byte |
| Text | String |
The integer aliases are accepted because they resolve to fixed integer primitives. The key expression must still have the table’s exact key type.
Floating-point numbers are not table keys in rc.3. Neither are arrays, records, classes, tables, functions, or unions. Rejecting unsupported keys at compile time keeps key equality deterministic and prevents a table from depending on an unspecified structural comparison.
String keys compare text
Section titled “String keys compare text”Two separate String values containing the same text address the same entry. Table lookup does not depend on whether the two strings occupy the same managed allocation.
local labels: {[String]: Int} = {}local key = String(42)
labels[key] = 1local found: Int? = labels["42"]The runtime compares string contents for table keys. That differs from the identity relationship of general managed objects.
Looking up a value
Section titled “Looking up a value”Use brackets to retrieve the value associated with a key:
local scores: {[String]: Int} = { ada = 10,}
local score = scores["ada"]The type of score is Int?, not Int.
For a table {[K]: V}, lookup has the conceptual type:
table-get({[K]: V}, K) -> V?The optional result represents both outcomes:
- the key exists, so the result contains its
Vvalue; - the key is absent, so the result is
nil.
Missing keys are therefore ordinary data, not traps. A table lookup has no array-style out-of-bounds failure.
Why every lookup is optional
Section titled “Why every lookup is optional”The compiler generally cannot prove which runtime mutations have occurred before a lookup. Consider a helper that receives a table and a name:
private function findScore( scores: {[String]: Int}, name: String,): Int? return scores[name]endThe function works for any String. Its signature must describe the missing-key case even if some callers normally pass a known name.
rc.3 does not yet provide full optional flow narrowing or a checked table lookup operation. In practical APIs, preserve the V? result until code that can deliberately handle absence.
Optional values do not mean deletion
Section titled “Optional values do not mean deletion”A table may itself have an optional value type:
local labels: {[String]: String?} = {}local inherited: String? = labels["base"]
labels["copy"] = inheritedStoring an existing String? value is allowed. It creates or replaces an entry; it does not remove the key.
Direct standalone nil does not currently widen into an optional value during table assignment:
-- Rejected in rc.3:-- labels["draft"] = nilThere is no deletion syntax in rc.3. Do not use optional storage as if it were a hidden delete operation.
Inserting and replacing values
Section titled “Inserting and replacing values”Table mutation uses the same bracket syntax on the left side of assignment:
local scores: {[String]: Int} = {}
scores["ada"] = 10 -- insertscores["ada"] = 12 -- replacescores["grace"] = 15 -- insertThe compiler checks two independent facts:
- the expression between brackets has type
K; - the expression on the right has type
V.
For {[String]: Int}, both of these are rejected:
local scores: {[String]: Int} = {}
-- Wrong key type.-- scores[1] = 10
-- Wrong value type.-- scores["ada"] = "ten"A mutation never changes the table’s declared type. An empty table is not an untyped bag that learns a new shape from each insertion.
Compound assignment is not a table operation
Section titled “Compound assignment is not a table operation”rc.3 supports compound assignment for locals, captures, class fields, and array elements. It does not support table entries as compound targets.
local scores: {[String]: Int} = { ada = 10,}
-- Not supported in rc.3:-- scores["ada"] += 1There is an additional semantic issue: scores["ada"] is Int?, so incrementing it would also require an explicit policy for a missing key. rc.3 leaves that policy to future table APIs rather than inventing a fallback.
Tables have reference identity
Section titled “Tables have reference identity”A table is a managed object. Assigning it to another local copies a reference to the same table, not all its entries.
local scores: {[String]: Int} = { ada = 10,}
local sameScores = scoressameScores["ada"] = 25
local updated: Int? = scores["ada"]updated observes the mutation made through sameScores. The same behavior applies when a table is passed to a function:
private function recordScore( scores: {[String]: Int}, name: String, score: Int,) scores[name] = scoreendThe function mutates the caller’s table object. There is no implicit copy-on-call.
This is useful for shared mutable indexes, but it also means you should make mutation visible in an API’s name and purpose.
Evaluation order
Section titled “Evaluation order”Pop evaluates a table operation’s parts once and in source order.
For lookup:
local value = makeTable()[makeKey()]the table expression is evaluated first, then the key expression. Neither expression is repeated internally.
For mutation:
makeTable()[makeKey()] = makeValue()the order is:
- evaluate the table expression;
- evaluate the key expression;
- evaluate the value expression;
- perform the insertion or replacement.
This order matters when any expression calls a function, allocates a value, or changes program state. A backend must preserve it.
A complete table API example
Section titled “A complete table API example”The following program separates mutation from lookup and makes the optional result explicit:
namespace examples.scoreboard
private function recordScore( scores: {[String]: Int}, name: String, score: Int,) scores[name] = scoreend
private function findScore( scores: {[String]: Int}, name: String,): Int? return scores[name]end
function main() local scores: {[String]: Int} = { ada = 10, grace = 18, }
recordScore(scores, "linus", 15) recordScore(scores, "ada", 25)
local ada: Int? = findScore(scores, "ada") local unknown: Int? = findScore(scores, "unknown")
print("Scores recorded; lookup results stay optional.")endThe important boundary is visible in findScore: a table can be mutated with an Int, while a lookup returns Int?. No dynamic fallback or fabricated default turns absence into 0.
How the compiler sees a lookup
Section titled “How the compiler sees a lookup”For this source:
local score = scores[name]the rc.3 pipeline performs these steps:
- Resolve
scoresto a table type such as{[String]: Int}. - Check that
namehas the exact key type,String. - Assign the lookup expression the result type
Int?. - Preserve a typed table-get operation in HIR.
- Lower it to the backend-neutral MIR table-get operation.
- Let the selected execution backend perform the runtime lookup.
Mutation follows the parallel path:
scores[name] = scoreThe checker validates name: String and score: Int, HIR retains a table-set operation, and MIR represents the insertion or replacement explicitly.
Keeping table operations explicit matters. A backend does not have to reconstruct table semantics from a generic call or an untyped memory operation.
How the current rc.3 runtime stores a table
Section titled “How the current rc.3 runtime stores a table”The bootstrap native runtime uses a managed table object with metadata and interleaved entry slots:
table handle | v+-------------------------------+| length | capacity | type maps |+-------------------------------+| key 0 | value 0 |+-------------------------------+| key 1 | value 1 |+-------------------------------+| ... |+-------------------------------+length is the number of occupied pairs. capacity is the number of pairs for which storage is currently reserved. The type maps describe whether key and value slots contain managed references that the collector must trace.
Lookup in the bootstrap runtime
Section titled “Lookup in the bootstrap runtime”The current native implementation scans entries from the beginning until it finds an equal key:
for each occupied pair: if stored key equals requested key: return stored value
return nilScalar key types compare their canonical value representation. String keys compare text contents.
This linear scan is an rc.3 bootstrap implementation detail, not a source-language complexity guarantee. Programs should not depend on tables always using a linear representation. The closed key rules allow later runtimes to choose a different representation without changing lookup results.
Mutation in the bootstrap runtime
Section titled “Mutation in the bootstrap runtime”Table set performs a similar scan:
- if the key is found, replace its value in place;
- if the key is absent, append a new key-value pair;
- if there is no spare capacity, grow the backing storage first.
Replacing a value does not move the entry. New entries are appended in insertion order. rc.3 does not yet expose general table iteration, so insertion order is mainly part of deterministic runtime behavior rather than a source-level traversal API.
The current growth calculation is max(current capacity, 2) * 2. Consequently, the first insertion into a zero-capacity table reserves room for four entries, and later full tables grow geometrically. This is an internal policy and may change independently of Pop source semantics.
Growth preserves identity
Section titled “Growth preserves identity”Growing a table may move its backing storage, but it does not create a new source-level table value. Existing aliases still refer to the same managed table identity.
scores -----+ +--> stable managed handle --> possibly grown storagesameScores -+That stable handle is why mutations remain visible through every alias after growth.
Managed keys and values
Section titled “Managed keys and values”Tables may hold scalar data and managed values. The runtime records the precise slot positions that contain references. For a {[String]: SomeClass} table, both the key slot and value slot need tracing; for {[Int]: UInt8}, neither slot is a managed reference.
When storage grows, the collector rebuilds this precise layout for the larger allocation and preserves the live entries. Runtime stores use the collector-aware storage path so references written into table slots remain visible to garbage collection.
If allocation during table creation or growth fails, the runtime follows the normal out-of-memory panic path. Missing lookup, by contrast, is represented by nil and is not a panic.
Semantic guarantees and implementation details
Section titled “Semantic guarantees and implementation details”It helps to separate what Pop code may rely on from how the bootstrap runtime currently achieves it.
| Source-level rule | Current rc.3 implementation |
|---|---|
| A key identifies at most one value. | Set scans for an equal key before appending. |
Missing lookup returns nil through V?. |
Native lookup returns the optional nil representation after a failed scan. |
| String keys compare by text. | The runtime performs a content comparison. |
| Assignment aliases the same table. | Aliases share a stable managed handle. |
| Growth must preserve entries and identity. | The collector grows interleaved storage behind that handle. |
| Evaluation happens once in source order. | HIR and MIR keep table get/set as ordered operations. |
The right column is valuable when reading compiler code or debugging a backend. It is not permission for application code to depend on allocation sizes, scan order, or raw slot layout.
Choosing between tables and other aggregates
Section titled “Choosing between tables and other aggregates”Use a table when keys are discovered while the program runs:
local usersById: {[Int]: String} = {}usersById[userId] = userNameUse an array when the key is a dense zero-based position:
local names: [String] = ["Ada", "Grace"]local first = names[0]Use a record when the fields are fixed and have meaningful names:
record User name: String score: IntendUse a class when you need nominal identity, methods, and mutable declared fields. A table should not replace a record or class merely to avoid declaring the data model.
Current rc.3 boundaries
Section titled “Current rc.3 boundaries”The rc.3 table foundation intentionally remains small. It has:
- typed construction;
- optional lookup;
- insertion;
- replacement;
- reference identity;
- interpreter and LLVM/native execution support.
It does not yet have:
- deletion;
- key or value enumeration;
- general table iteration;
- a source-level length or capacity operation;
- checked lookup that directly yields
V; - a caller-provided missing-key default;
- table-entry compound assignment;
- structural table equality;
- compile-time table construction or mutation;
- C-backend execution support.
The experimental C backend fails closed when it encounters unsupported table operations. It does not silently substitute different behavior.
Common mistakes
Section titled “Common mistakes”Omitting the expected type
Section titled “Omitting the expected type”-- Too little information:-- local values = {}
local values: {[String]: Int} = {}Expecting a non-optional lookup
Section titled “Expecting a non-optional lookup”local values: {[String]: Int} = {}
-- Lookup can miss.-- local value: Int = values["answer"]
local value: Int? = values["answer"]Mixing key types
Section titled “Mixing key types”local values: {[Int]: String} = {}
values[1] = "one"-- values["2"] = "two"Treating assignment as deletion
Section titled “Treating assignment as deletion”Table set always means insert or replace. rc.3 has no delete operation, including for optional-valued tables.
Assuming a hash-table performance contract
Section titled “Assuming a hash-table performance contract”The Pop type is called a table because it models typed key-value associations. rc.3 does not promise a particular hashing strategy or asymptotic lookup complexity.
Exercises
Section titled “Exercises”- Create a
{[UInt16]: String}table, insert two port names, replace one, and annotate both lookup results. - Pass a table to a helper that inserts an entry, then look up that key through the original local to observe shared identity.
- Rewrite a small record-shaped table as a record. Notice which keys become declared fields and which optional lookups disappear.
- Try a
Float64key and read the compiler diagnostic. Then replace it with an integer orStringrepresentation chosen deliberately for the domain. - Design a function that returns
V?instead of inventing a default for a missing table key.
The central rule is simple: a table mutation accepts exactly K and V, while a lookup returns V?. The compiler preserves that rule through HIR and MIR, and the runtime implements it with deterministic managed storage.
