Integers

Numbers are at the heart of almost every program. This page covers the integer types Loft provides, the arithmetic and bitwise operations you can perform on them, how to convert between numbers and text, and what Loft does when something goes wrong — such as dividing by zero.

The default number type is integer: a 64-bit signed whole number, the same as i64 in Rust. It can hold values from about −9 quintillion to +9 quintillion (roughly ±9.2 × 10^18), so everyday counts and very large numbers both fit in the one type — there is no separate long. For decimal (fractional) numbers, see the Float page.

fn main() {

Converting between numbers and text

Wrapping a value in {...} inside a string formats it as text. Going the other way, as integer? parses a text value into a number. Write the ?: a text parse can fail, so a bare as integer is refused at compile time ("a text parse as integer may fail, and a bare cast asserts it cannot"). The ? is you accepting the failure case, and the result is null — not a crash — when the text does not parse.

  v = 4;
  assert("{v}" == "4", "Format integer as text");
  assert("123" as integer? == 123, "Parse text to integer");
  assert(!("abc" as integer?), "Unparseable text gives null");

Arithmetic and operator precedence

Loft follows standard mathematical precedence: * and / before + and -. Bitwise operators (<<, &, ^) have their own precedence — when mixing them with arithmetic, parentheses make your intent clear and avoid surprises. Note: ^ is XOR, not exponentiation. For powers use pow(base, exp) — a FLOAT function, so pow(2, 3) does not resolve: raise in floats and come back. It answers a NULLABLE float (float?), because a power can be uncomputable, so the trip back is a checked cast or a discharge: pow(base, exp) as integer? // the number, or null (pow(base, exp) ?? 0.0) as integer // your own default instead A bare as integer is refused ("cannot cast a possibly-null float? to the non-null integer") unless loft can prove the power is defined: a whole-number constant exponent, or a base it knows is not negative. That proof depends on the arguments, so it does not generalise — write the ? and it works for any arguments.

  assert(1 + 2 * 4 == 9, "Multiplication before addition");
  assert(1 + 2 << 2 == 12, "Shift: (1+2) << 2 = 12");
  assert(0x0a8 & 15 == 8, "Bitwise AND masks low 4 bits");
  assert(42 ^ 0b111111 == 21, "Bitwise XOR");
  assert(105 % 100 == 5, "Modulus (remainder)");

Written with the ? on purpose: it keeps compiling whatever numbers a reader puts in.

  pw_base = 2.0;
  assert(pow(pw_base, 3.0) as integer? == 8, "pow() for exponentiation");
  assert((pow(pw_base, 10.0) ?? 0.0) as integer == 1024, "…or discharge, then cast");

abs() returns the absolute value — the distance from zero, always positive.

  assert(1 + abs(-2) == 3, "abs(-2) == 2");

Division by zero — produces null and keeps running

Most languages crash on division by zero. Loft does NOT: a divide (or modulo) by zero is uncomputable, so it produces null and execution CONTINUES — the spreadsheet model, where one bad cell shows an error but the rest still recalculate. This holds the same everywhere (development, test, and production) — one bad calculation never halts the run. Where the divisor is a literal zero in your source, loft can see it before the program runs and warns (next section). Where it is only zero at RUN time there is no warning — you get null, and {12 / a} prints null. Recover with ??, or test the result for null: nothing else tells you, so a divide whose divisor could be zero is one you handle rather than one you are reminded about.

  a = 2 * 2;
  a -= 4;

a is now 0

  assert(!(12 / a ?? null), "Division by zero gives null when defended with `?? null`");

Warning when you write a literal zero divisor

When the divisor is a literal 0 written directly in your source code, loft warns you while reading your code (before running it), because that is almost certainly a mistake: n / 0 // warning: Division by constant zero n % 0 // warning: Modulo by constant zero Use a variable (like a above) when you intentionally want null-on-zero division without a warning.

Embedding integers in text

  assert("a{12}b" == "a12b", "Integer in format string");

A full expression can appear inside {...}, not just a variable name.

  assert("a{1 + 2 * 3}b" == "a7b", "Expression in format string");

Number format specifiers

After a : inside {...} you can control how a number is displayed: #x — hexadecimal with 0x prefix o — octal b — binary + — always show a sign (+ or -) N — minimum field width (space-padded on the left) 0N — minimum field width (zero-padded on the left)

  assert("a{1+2+32:#x}b" == "a0x23b", "Hex format with 0x prefix");
  assert("{12:o}" == "14", "Octal");
  assert("{12:+4}" == " +12", "Sign and width");
  assert("{1:03}" == "001", "Zero-padded width");
  assert("{42:b}" == "101010", "Binary");

Hexadecimal literals in source code accept both lower and upper case digits.

  assert(0xff == 255, "Lowercase hex literal");
  assert(0xFF == 255, "Uppercase hex literal");
  assert(0x2A == 42, "Uppercase hex digit");

Large integers

Because integer is 64-bit, values far past the 32-bit limit of 2 billion work directly — no separate type is needed. Arithmetic, comparisons, and the format specifiers above all behave the same at these magnitudes.

  big = 1000000000 * 5;
  assert(big == 5000000000, "Integers well past 2 billion");
  assert("{big}" == "5000000000", "Large integer formatted as text");

Common pitfall: integer overflow

A calculation whose result is too large to represent overflows — and in loft an overflow is uncomputable, so the result is null. It never wraps to a silently-wrong number, and it never crashes; execution continues with the null, exactly like divide-by-zero above. Check the result for null (or keep intermediate values in range) when multiplying or summing very large numbers.

A sized integer (u8, i16, a field declared integer limit(0, 255)) has a smaller range, so it overflows sooner — and where it lands depends on one thing: whether the slot can hold null. A nullable one (u8?) gets the null, the same as above. A non-nullable one has nowhere to put it, so it gets the type's default instead — 0 for every range that includes zero. What you never get is a wrapped number.

  small: u8? = 250;
  small += 10;
  assert(small == null, "A nullable sized integer overflows to null");
  assert((small ?? 0) == 0, "…and '??' recovers from it, like any other null");
  fixed: u8 = 250;
  fixed += 10;
  assert(fixed == 0, "A non-nullable one takes its type's default instead");
}