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,
}
struct OptTextHolder {
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 64-bit integer (-9 223 372 036 854 775 808) 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 -9 223 372 036 854 775 808. When defended with ?? null (or a following null-check), division by zero produces this same value, so both paths look the same to your code:
zero = 0;
n = 1 / zero ?? null;
assert(!n, "div-by-zero defended with `?? null` is null");
assert(n != 0, "null is not zero; it is -9 223 372 036 854 775 808");
assert(n < 0, "i64::MIN is the most negative 64-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. A text? local or field can hold it; a plain text cannot, and a parse respects that.
holder = NullTextHolder.parse(`{{}}`);
assert(holder.s == "", "a missing `text` field is empty, because it cannot be null");
opt = OptTextHolder.parse(`{{}}`);
assert(!opt.s, "a missing `text?` field IS null — the declaration decides");
empty = "";
assert(empty, "empty string is NOT null — this surprises most newcomers");
Parsing text to a number needs a fallback
An unchecked text as integer is a COMPILE error: parsing can fail, and loft refuses to let that failure slip through as a silent null. Ask for a checked cast with integer? (yields the number or null), or supply a default with ?? <value>. The same rule applies to float.
parsed = "42" as integer?;
assert(parsed == 42, "checked cast `as integer?` yields the number");
fallback = "oops" as integer? ?? -1;
assert(fallback == -1, "unparseable text falls back to the default");
Integer overflow yields null
A calculation that overflows the integer range is uncomputable, so loft yields null and keeps running — it never wraps to a silently-wrong number (the way C, or a Rust release build, would) and it never crashes. One bad value degrades locally; the rest of the program still computes. huge = 9223372036854775807; huge + 1 → null (not a wrapped negative) To trace where overflows arise, opt into the debug log level. Mitigation: check the result for null, or keep intermediate values within range.
huge = 9223372036854775807;
assert(! (huge + 1), "overflow yields null, not a wrapped value");
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 compares uniformly with every other scalar
Floats store null as NaN internally, but null COMPARISON is uniform with every other scalar type (@PLN102 null model): null == null is TRUE, null is not equal to any real value, and null orders as the low extreme. Detect a null float with f == null (or !f / f ?? default) — NOT the old f != f trick, which no longer works now that null == null is true. Both the defended and the undefended division yield null and continue (C80 / E-Uncomp — see tests/scripts/184-i333-div-zero-null-continues.loft); the undefended site additionally reports a warning.
bad = 0.0 / 0.0 ?? null;
assert(!bad, "a null float is falsy");
assert(bad == null, "detect a null float with `== null`");
assert(bad == bad, "null == null is true (uniform null model)");
assert(! (bad != null), "a null float `!= null` is false");
assert(bad != 0.0, "null != 0.0 is true");
Use f == null, !f, or f ?? default to check for null floats.
Text length counts characters; size counts bytes
len() on text returns the number of characters (Unicode code points) — the human count. size() returns the number of UTF-8 bytes; multi-byte characters (accented letters, emoji, CJK) each occupy 2-4 bytes. The two values differ whenever the text contains any multi-byte character.
emoji = "Hi 😊!";
assert(len(emoji) == 5, "5 characters (H, i, space, 😊, !)");
assert(size(emoji) == 8, "8 UTF-8 bytes (the emoji is 4): {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.
Text file reading assumes UTF-8
lines() and content() read a file as UTF-8 text and crash on invalid UTF-8 (a binary file, or a different encoding such as Latin-1). For binary data, set a binary format on the file — f#format = LittleEndian (or BigEndian) — and read raw bytes / integers directly (see 13-file.loft). So: read UTF-8 text in text mode, everything else in a binary format.
XOR is ^, not exponentiation
Unlike some languages where ^ means "power", in loft ^ is bitwise XOR. For exponentiation use the ** operator (2 ** 10 == 1024, 2.0 ** 3.0 == 8.0) or the pow() function. Watch one precedence footgun: -x ** y parses as -(x ** y), so loft warns on it — write (-x) ** y when you mean to raise a negated base.
assert((0b1010^0b1100) == 0b0110, "^ is XOR");
assert(2**10 == 1024, "** is the power operator");
assert(pow(2.0, 3.0) == 8.0, "pow() also computes powers");
}