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. Helper used in the ?? double-evaluation example below. Increments the call counter and returns the given value unchanged.
fn counted_call(calls: &integer, value: integer) -> integer {
calls += 1;
value
}
Used to obtain a null text value (an uninitialized field is the NUL sentinel).
struct NullTextHolder { s: text }
fn main() {
Null values — hidden reserved values
Every type reserves one special value to mean "nothing here" (null). That reserved value looks like any other value, so be aware of what it is for each type:
boolean—falseis the null valueinteger— the most negative 32-bit integer (-2 147 483 648) is nulllong— the most negative 64-bit integer is nullfloat/single—NaN(Not a Number) is nullcharacter— the NUL character (code point 0) is nulltext— a single NUL character ('\0') is null; the empty string""is NOT nullreference— record 0 is null- plain
enum— byte value 255 is null (limits enums to 255 variants)
This means there is one value per type that you cannot distinguish from null. For integers, that is -2 147 483 648. Division by zero also produces this same value, 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 is not zero; it is -2 147 483 648");
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");
Text null is the NUL character (\0), not the empty string. Parsing a JSON object with a missing text field produces the NUL sentinel:
holder = NullTextHolder.parse(`{{}}`);
assert(!holder.s, "missing JSON text field is null (the NUL sentinel)");
empty = "";
assert(empty, "empty string is NOT null — this surprises most newcomers");
Mitigation: Use long when you need the full 32-bit integer range, or declare struct fields as not null so that reserved value can be used as data.
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. Design: a future --debug flag will turn silent overflow into a logged WARN with file:line context so overflows are visible during development without affecting release performance.
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. size() returns the number of Unicode code points (characters). These two values differ whenever the text contains any multi-byte character.
emoji = "Hi 😊!";
assert(len(emoji) == 8, "5 visible chars but 8 bytes (emoji is 4 bytes)");
assert(size(emoji) == 5, "size() counts code points: {size(emoji)}");
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 exactly once
The ?? operator means "use this value, or if it is null, use the right side instead". The left-hand expression is evaluated exactly once regardless of whether the result is null. The example below uses counted_call (defined above) to verify this.
calls = 0;
qq_result = counted_call(calls, 7) ?? 99;
assert(calls == 1, "?? evaluated left side exactly once: {calls}");
assert(qq_result == 7, "result is the value from that single evaluation: {qq_result}");
This means result = expensive_call() ?? default is safe: the function is called once. If it returns null, default is used. There is no double call.
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. The ** operator (Python/Ruby exponentiation) does not exist either. Use the pow() function for exponentiation.
assert((0b1010 ^ 0b1100) == 0b0110, "^ is XOR");
assert(pow(2.0, 3.0) == 8.0, "use pow() for power");
}