Loft catches many errors at compile time, but a few surprises remain at runtime. This page catalogues every known trap so you can write confident code from day one. Each section includes a live example that proves the described behavior.
fn main() {
Null sentinels
Every type uses a special in-band value to represent null. The value depends on the type:
- `boolean` uses `false`
- `integer` uses `i32::MIN` (-2 147 483 648)
- `long` uses `i64::MIN`
- `float` and `single` use `NaN`
- `character` uses the NUL character
- `text` uses an internal null pointer
- `reference` uses record 0
- plain `enum` uses byte 255 (limits enums to 255 variants)
This means there is one value per type that you cannot distinguish from null. For integers, that value is `i32::MIN`. Division by zero also produces `i32::MIN`, so both paths look the same to your code:
zero = 0;
n = 1 / zero;
assert(!n, "div-by-zero is null");
assert(n != 0, "null sentinel is not zero; it is i32::MIN");
assert(n < 0, "i32::MIN is the most negative 32-bit integer");
Arithmetic on null propagates: null plus anything is null.
assert(!(n + 1), "null + 1 is still null");
Mitigation: Use `long` when you need the full 32-bit range, or declare struct fields as `not null` to reclaim the sentinel value.
Integer overflow wraps silently
32-bit integers wrap when they exceed roughly 2 billion. There is no runtime overflow check and no exception. The result is a small or negative number and the program continues as if nothing happened. The following would wrap silently in a release build: big = 2000000000; big + big → negative number Mitigation: Use `long` (64-bit) when multiplying or summing large values: `big as long + big as long` avoids the wrap.
big = 2000000000;
assert(big as long + big as long == 4000000000l, "long avoids wrap");
Bitwise operators with zero
All bitwise operators (AND, OR, XOR, shift) work correctly with zero. Zero is the identity element for OR, XOR, and shift; zero for AND.
assert(0b1010 & 0 == 0, "AND with 0: zero");
assert(0b1010 | 0 == 0b1010, "OR with 0: identity");
assert(0b1010 ^ 0 == 0b1010, "XOR with 0: identity");
assert(5 << 0 == 5, "shift left by 0: identity");
assert(5 >> 0 == 5, "shift right by 0: identity");
Float null is NaN
Floats represent null as `NaN` (Not a Number). Null floats behave consistently with other null types: null is not equal to anything (including itself), and null is not-equal to everything.
bad = 0.0 / 0.0;
assert(!bad, "NaN is falsy — this is how you detect null floats");
assert(!(bad == bad), "null == null is false");
assert(bad != bad, "null != null is true");
assert(bad != 0.0, "null != 0.0 is true");
Use `!f` or `f ?? default` to check for null floats.
Text length counts bytes, not characters
`len()` on text returns the number of UTF-8 bytes, not the number of visible characters. Multi-byte characters (accented letters, emoji, CJK) each occupy 2-4 bytes.
emoji = "Hi 😊!";
assert(len(emoji) == 8, "5 visible chars but 8 bytes (emoji is 4 bytes)");
Slicing and indexing also use byte offsets. Slicing in the middle of a multi-byte character is an error. Mitigation: Use `for c in text` to iterate by character. Use `c#index` and `c#next` to get the byte boundaries of each character.
count = 0;
for c in emoji { count += 1; }
assert(count == 5, "for-loop iterates by character, not byte");
`#index` means different things on text and vectors
On a text loop, `c#index` is the byte offset of the current character. On a vector loop, `v#index` is the 0-based element position. Both are called `#index` but represent different units.
v = [10, 20, 30];
idx = 0;
for x in v { idx = x#index; }
assert(idx == 2, "vector #index: element position (0-based)");
Text #index is a byte offset, not a character count:
t = "aé";
byte_pos = 0;
for c in t { byte_pos = c#index; }
assert(byte_pos == 1, "text #index: byte offset of 'é' (byte 1, not char 1)");
`??` evaluates the left side twice for complex expressions
The null-coalescing operator `??` checks if the left side is null and returns the right side if so. For a simple variable this is fine, but for a function call or complex expression the left side is evaluated once for the null check and once for the result. Mitigation: Assign complex expressions to a temporary variable first. For example, instead of `result = expensive_call() ?? default` (which calls the function twice), write `temp = expensive_call()` on one line and then `result = temp ?? default` on the next.
Text indexing and slicing return different types
`txt[i]` returns a `character` (a single Unicode scalar value). `txt[i..j]` returns `text` (a UTF-8 string). These are different types.
txt = "hello";
ch = txt[0];
slice = txt[0..1];
assert(ch == 'h', "indexing returns a character");
assert(slice == "h", "slicing returns text");
Building text from characters requires format interpolation:
result = "";
for c in "abc" { result += "{c}"; }
assert(result == "abc", "characters must be formatted into text");
Format strings: braces are always interpreted
Every string literal in loft is a format string. Literal braces must be escaped as `{{` and `}}`:
n = 42;
assert("{n}" == "42", "single braces: format expression");
assert(len("{{}}") == 2, "double braces produce literal brace chars");
Forgetting to escape braces in expected output is a common mistake in assertions and comparisons.
Hash collections cannot be iterated
Hashes are lookup structures, not ordered collections. You cannot write `for item in my_hash { }`. If you need both fast lookup and ordered iteration, keep a vector and a hash pointing at the same record type. See the Hash documentation page for the recommended pattern.
Mutation guard blocks appending during iteration
The compiler prevents `v += [x]` inside `for e in v`. This protects against infinite loops. The guard also catches field access: `for e in db.items { db.items += [x]; }` is blocked too. The only allowed mutation is `e#remove` inside a filtered loop.
If-expression requires else when used as a value
Using `if` as a value expression without an `else` clause is a compile error. This prevents accidental null values from missing branches. If-statements (where the body has no value) do not need else. For example, `x = if cond { 1 }` is an error; write `x = if cond { 1 } else { 0 }`.
Match guards do not prove a variant is handled
A guarded arm like `Red if cond => ...` does not count as handling the `Red` variant because the guard can fail at runtime. Even if every variant has a guarded arm, you still need a wildcard `_` or an unguarded arm so the compiler knows every case is covered.
Ref-parameter semantics
Without `&`, appending to a vector parameter is local — the caller's vector does not grow. With `&`, the caller sees the new elements. Field-level mutations (e.g. `v[i].field = x`) are always visible to the caller because both sides share the same underlying database reference. Rule of thumb: Use `&vector<T>` when the function needs to grow the vector. Use plain `vector<T>` when the function only reads or modifies existing elements.
File I/O assumes UTF-8
All file reading in loft assumes the file content is valid UTF-8. Reading a binary file or a file in a different encoding (e.g. Latin-1) will crash the program at runtime. There is currently no way to read raw bytes. Mitigation: Only read files you know to be UTF-8. If you need to process binary data, convert it to UTF-8 externally before passing it to loft.
XOR is `^`, not exponentiation
Unlike some languages where `^` means "power", in loft `^` is bitwise XOR. Use the `pow()` function for exponentiation.
assert((0b1010 ^ 0b1100) == 0b0110, "^ is XOR");
assert(pow(2.0, 3.0) == 8.0, "use pow() for power");
}