Loft and Python share a similar surface syntax — assignment without type annotations, brace-free expressions, and names that just work. But loft is statically typed and compiled to bytecode, while Python is dynamically typed and interpreted. This page documents the most important differences so that Python programmers know exactly what they gain and what they give up.
1. Variables — static types inferred at first assignment
Loft
x = 42; // integer — fixed at first assignment
x += 1;
name = "Bob"; // text — a different variable, not a rebind
// x = "oops"; // compile error: cannot assign text to integer
Python
x = 42 # int inferred; can be rebound to any type
x += 1
name = "Bob"
x = "oops" # fine — x is now a str
Upside Type errors are caught before the program runs. Renaming or reshaping data structures surfaces mismatches immediately. There is no equivalent of Python's runtime TypeError, AttributeError, or "NoneType has no attribute X" crash — those classes of bug are detected at compile time.
Downside Types are fixed at first assignment and cannot change. Python's dynamic typing genuinely speeds up prototyping: a function that accepts anything, a list that holds mixed types, or a variable that starts as an integer and becomes a float later are all natural in Python and impossible in loft. Adding a type annotation layer (mypy, pyright) gives Python static safety without giving up its flexibility.
2. Null — structured absence, not a universal wildcard
Loft
struct User {
email: text?, // `?`: may be absent
age: integer, // never null — the default
}
u = User { age: 30 };
if u.email == null { print("no email\n"); }
u.age = null; // warns: the slot holds null anyway
Python
from dataclasses import dataclass
from typing import Optional
@dataclass
class User:
age: int
email: Optional[str] = None # explicit Optional
u = User(age=30)
if u.email is None:
print("no email")
u.age = None # allowed at runtime; mypy catches this
Upside Nullability is part of the type and readable at a glance, and the default is the safe one: a field is non-null unless it says ?, so absence is something you opt INTO. No Optional[T] wrapping is needed; the comparison v == null is natural, and ?? supplies a fallback at the point of use.
Downside The default is the safe one — a field is non-null unless its type says ? — but nothing forces you to discharge a nullable at the point of use, and writing null into a non-null slot is a warning rather than a refusal. Python with mypy and an Optional[T] annotation reports that write as an error, where loft only warns.
3. Structs vs classes and dicts
Loft
struct Point { x: float, y: float }
p = Point { x: 1.0, y: 2.0 };
p.x += 0.5;
// p.z; // compile error: no field z on Point
// p.x = "hi"; // compile error: cannot assign text to float
fn distance(self: const Point) -> float {
sqrt(self.x * self.x + self.y * self.y) ?? 0.0
}
Python
from dataclasses import dataclass
import math
@dataclass
class Point:
x: float
y: float
def distance(self) -> float:
return math.sqrt(self.x**2 + self.y**2)
p = Point(x=1.0, y=2.0)
p.z # AttributeError at runtime (or dict: KeyError)
p.x = "hi" # allowed at runtime; mypy catches this
Upside Field access is checked at compile time — typos in field names are caught before any code runs. Struct memory layout is fixed and unboxed; integer and float fields live directly in memory with no heap allocation overhead. Methods are ordinary named functions — they can be added from any file, at any time, without modifying the struct definition.
Downside No inheritance, no properties or descriptors. Printing needs no __repr__ — "{p}" renders every field — and == compares every field with no __eq__ written, as @dataclass's generated one does (&a == &b asks what Python's is asks). A struct can overload operators by defining them (OpAdd for +, OpEq for ==, OpLt for <). Python also supports plain dicts as lightweight records, which is often more convenient for ad-hoc data.
4. while loop — yes, and while true; no while ... else
Loft
while !ready() { step(); } // works
while len(queue) > 0 {
process(queue[0]);
queue.remove(0); // `#remove` is for a loop variable
}
// An infinite loop, left by a break:
while true {
if should_exit() { break; }
step();
}
Python
while not ready():
step()
while queue:
process(queue.pop(0))
while True: # truly infinite
if should_exit(): break
step()
Upside while condition { } works exactly as expected. Filtered loops (for x in v if pred(x)) and loop attributes (x#first, x#count) add expressive power to for without needing a separate loop form.
Downside No while ... else (executes when the condition becomes false without a break), a Python pattern with no clean equivalent in loft.
5. Polymorphic enum dispatch vs isinstance
Loft
enum Shape {
Circle { r: float },
Rect { w: float, h: float }
}
fn area(self: Circle) -> float { PI * pow(self.r, 2.0) }
fn area(self: Rect) -> float { self.w * self.h }
s.area(); // dispatches on the runtime variant
Python
import math
from dataclasses import dataclass
@dataclass
class Circle: r: float
@dataclass
class Rect: w: float; h: float
def area(s):
if isinstance(s, Circle): return math.pi * s.r ** 2
if isinstance(s, Rect): return s.w * s.h
# Python 3.10+ structural pattern matching:
match s:
case Circle(r=r): return math.pi * r ** 2
case Rect(w=w, h=h): return w * h
Upside Each variant's behaviour lives in its own small, named function — easy to read, easy to navigate in an editor. Adding a new shape only requires a new fn area(self: NewShape) with no changes to existing code or a central dispatch function. Calling a method on a value held as the enum is refused until every variant has an implementation, or the enum has a fallback (fn area(self: Shape)).
Downside A missing implementation surfaces at the first call through the enum, not where the variant is declared, and an empty-body stub answers the type's default rather than anything like None. Python 3.10+ structural pattern matching (match/case) fully destructs the matched value and is exhaustive when case _: is present. Python also supports inheritance-based polymorphism (class Circle(Shape)) and abstract base classes, giving far richer dispatch options.
6. String formatting — embedded expressions
Loft
msg = "Hi {name}, score: {score:8.2}";
sign = "seed {n:+}"; // sign shows on integers: "seed +42"
hex = "{n:#x}";
list = "{for x in 1..4 {x*2}}"; // [2,4,6]
pad = "{count:08}"; // zero-padded (integers)
Python
msg = f"Hi {name}, score: {score:8.2f}"
sign = f"seed {n:+}"
hex = f"{n:#x}"
# no loop in f-string; use a join:
lst = ",".join(str(x*2) for x in range(1, 4)) # "2,4,6"
pad = f"{count:08}"
Upside All loft strings are implicitly format strings — no f prefix needed. Inline for loops inside {} produce a bracketed list ([2,4,6]) without a separate join. Format specifiers mirror Python's f-string mini-language: width, sign, alignment and zero-padding work on integers and floats alike, precision on floats, and radix (#x, #o, b) on integers: {5.25:+} is +5.25 and {5.25:08} is 00005.25.
Downside Both accept an expression inside the braces — a method call with a specifier ({obj.method():.2}), an if expression ({if ok {"yes"} else {"no"}}), a join. Python also supports conversion flags (!r for repr, !s for str, !a for ASCII) and the = debug specifier ({x=} prints x=42); loft has none of these.
7. Collections — typed and built-in
Loft
nums: vector<integer> = [1, 2, 3];
lookup: hash<Entry[key]> = [];
lookup += [Entry { key: "key", value: "value" }];
scores: sorted<Score[user]> = [];
scores += [Score { user: "ann", points: 95 }]; // keyed insert: binary search, kept in key order
// Element type is enforced at compile time:
// nums += ["x"]; // compile error: expected integer
Python
nums = [1, 2, 3] # list — any element type
lookup = {} # dict — any key/value type
lookup["key"] = "value"
# sorted dict requires an external package:
from sortedcontainers import SortedDict
scores = SortedDict()
scores[user] = 95
nums.append("x") # allowed at runtime
Upside All collection types — vector, hash, and sorted map — are built in; no extra import or install needed. Element types are checked at compile time. The same [] indexing syntax works on all three. for x in v if pred(x) { v#remove; } safely removes the current element while iterating — something Python requires careful index management for.
Downside Python's built-in dict and list are among the most heavily optimised data structures in any scripting runtime. Python also has set and frozenset (no loft equivalent), and ordered insertion semantics on dict (Python 3.7+), where a loft hash iterates in key order. Loft has comprehensions too ([for x in v if x > 0 { x * 2 }]), but they and the map() / filter() / reduce() higher-order functions build a whole vector at each stage, while Python's generators are lazy and allocation-free until consumed.
8. Closures — same-scope capture works
Loft
// Capture works when called in the same scope:
offset = 10;
shift = fn(x: integer) -> integer { x + offset };
assert(shift(5) == 15, "shift");
// Lambdas work with map/filter/reduce, capturing or not:
doubled = map([1, 2, 3], fn(x: integer) -> integer { x * 2 });
evens = filter([1, 2, 3, 4], |x| { x % 2 == 0 });
// Capturing lambda with map:
offset = 10;
shifted = map(nums, fn(x: integer) -> integer { x + offset });
Python
offset = 10
shift = lambda x: x + offset # capture works anywhere
assert shift(5) == 15
nums = [1, 2, 3, 4, 5]
doubled = list(map(lambda x: x * 2, nums))
evens = list(filter(lambda x: x % 2 == 0, nums))
# capturing lambda passed to map:
shifted = [x + offset for x in nums]
Upside Capture works for every type — scalars, text, structs and every collection kind — with no capture modes and no scope surprises. The compiler picks the mode from the type, so an accumulator needs no declaration the way Python's nonlocal does. Both long-form (fn(x: integer) -> integer { x * 2 }) and short-form (|x| { x * 2 }) lambdas are supported. Named function references (a bare name: map(v, double)) are compile-checked. Higher-order functions (map, filter, reduce) accept both lambdas and fn-refs.
Downside You do not choose the capture mode, and it is not the same for every type. A scalar or text the lambda only READS is copied at definition time, so a later mutation of the original does not reach it — where Python closes over the name and would see the new value. Structs and collections share the way Python does, and so does a scalar the lambda writes to. Three limits have no Python counterpart: a capturing closure cannot be stored in a collection (a struct field holds one fine), a lambda cannot write a captured &integer or &text parameter (it holds a copy), and a scalar the lambda writes to may be captured by only one lambda. A comprehension is the short form in both languages: [for x in v { x + offset }] in loft, [x + offset for x in v] in Python.
9. No exception handling — file errors use FileResult
Loft
// Logic errors: assert aborts the program
fn divide(a: float, b: float) -> float {
assert(b != 0.0, "division by zero");
a / b ?? 0.0
}
// Reading: check f#exists before using content
f = file("data.txt");
if f#exists {
data = f.content();
print("{len(data)} characters\n");
} else {
print("file not found\n");
}
// Mutating ops return a FileResult enum; match on the bare variant
result = delete("old.txt");
match result {
Ok => print("deleted\n"),
NotFound => print("not found\n"),
PermissionDenied => print("permission denied\n"),
IsDirectory => print("is a directory\n"),
_ => print("other error\n"),
}
// Or just check success:
if !move("a.txt", "b.txt").ok() { print("rename failed\n"); }
Python
# Logic errors: raise an exception
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("division by zero")
return a / b
try:
result = divide(x, y)
except ValueError as e:
print(f"error: {e}")
# File errors: catch specific exception types
try:
with open("data.txt") as f:
data = f.read()
except FileNotFoundError:
print("file not found")
try:
os.remove("old.txt")
except FileNotFoundError:
print("not found")
except PermissionError:
print("permission denied")
except IsADirectoryError:
print("is a directory")
Upside File errors are represented as a typed FileResult enum — Ok, NotFound, PermissionDenied, IsDirectory, NotEmpty, Other — so every failure case is named and exhaustively matchable. No accidental exception swallowing, no hidden exit paths, no stack-unwinding overhead. The control flow of every loft function is straightforward to read.
Downside Logic errors (assert, panic) abort the entire program — there is no try/except to catch them. Python's exception hierarchy supports retry logic, cleanup via finally, and graceful degradation from arbitrary errors anywhere in the call stack — patterns that are not expressible in loft.
10. Built-in parallel for-loops — par(...)
Loft
fn score(item: const Record) -> integer { item.value * 2 }
total = 0;
for item in records par(s=score(item), 4) {
total += s; // results arrive in original order
} // 4 worker threads, no GIL
Python
from concurrent.futures import ProcessPoolExecutor
def score(item): return item.value * 2
# ProcessPoolExecutor: bypasses GIL via separate processes
with ProcessPoolExecutor(max_workers=4) as ex:
results = list(ex.map(score, records))
total = sum(results)
# ThreadPoolExecutor: simpler but GIL limits CPU-bound work
Upside Built into the language — no import, no boilerplate. The GIL does not apply; worker threads run on separate OS threads inside the same process. Results arrive in the original vector order. The compiler validates the worker function signature at the call site. The thread count is set per call, making it easy to tune for the hardware.
Downside A worker is a named function, not a lambda, and context from the calling scope reaches it only as extra scalar arguments: each worker runs on its own copy of the heap, so it cannot reach a struct or vector of the caller's. Python's ProcessPoolExecutor works with any picklable object and gives full control over timeouts, cancellation, and error propagation.
11. Generic functions — interface bounds, no duck typing
Loft
// Pass-through generics work:
fn identity<T>(x: T) -> T { x }
identity(42); // integer
identity("hello"); // text
// Interface bounds work, and are satisfied structurally:
fn largest<T: Ordered>(a: T, b: T) -> T { if a > b { a } else { b } }
largest(3, 9); // 9
largest("ada", "bob"); // "bob" — same function
// A per-type version is still what you write when the bound does not fit:
fn max_int(a: integer, b: integer) -> integer {
if a > b { a } else { b }
}
Python
# Duck typing: works for any T that supports >
def max_val(a, b):
return a if a > b else b
# With type hints (Python 3.12+):
from typing import TypeVar
T = TypeVar("T")
def max_val(a: T, b: T) -> T:
return a if a > b else b
Upside Basic generic functions (pass-through, wrapping, forwarding) work out of the box. Collections (vector<T>, hash<T>, sorted<T>) are generic at the engine level, covering the most common need without any user-visible type parameter syntax.
Downside Operations on T require an explicit interface bound (<T: Ordered> for comparison, <T: Addable> for arithmetic, <T: Printable> for display); unbounded T stays opaque. Python's duck typing lets one function work on any type that supports the needed operation (len, >, +) with no annotation, and Python 3.12 TypeVar/Protocol add optional static checking on top.
12. Function signatures — defaults and named args, but no *args
Loft
fn connect(host: text, port: integer = 80, tls: boolean = true) -> text { "{host}:{port} tls={tls}" }
connect("localhost"); // uses defaults
connect("localhost", tls: false); // named arg — skips port
connect("localhost", 443, false); // positional — all explicit
Python
def connect(host: str, port: int = 80, tls: bool = True):
...
connect("localhost") # uses defaults
connect("localhost", tls=False) # keyword arg — skips port
def log(*args, **kwargs): ... # variadic
Upside Default parameter values and named arguments work similarly to Python. connect("host", tls: false) skips defaulted middle parameters. Every call site is readable without jumping to the function definition.
Downside No *args or **kwargs — variadic dispatch must use a vector parameter. Python's flexible argument syntax also enables decorator patterns and configuration-driven code that is harder to express in loft.
13. Ecosystem — minimal standard library
Loft
// `use mylib;` loads a library — a .loft file beside the script,
// or a package the manifest declares under [dependencies].
// The standard library ships inside the interpreter binary, and
// `loft install` fetches the rest from a signed registry.
// Built-in: text, math, file I/O, collections,
// logging, threading, lexer/parser
Python
import numpy as np # numerical arrays / SIMD
import pandas as pd # dataframes
import requests # HTTP
import flask # web framework
import sqlalchemy # database ORM
import scikit_learn # machine learning
# 500 000+ packages on PyPI, installable with:
# pip install <package>
Upside The interpreter is a single self-contained binary with the standard library inside, so a program that needs nothing else deploys by copying one file, with no virtual environment. What it does need, loft.toml declares and loft install fetches from a signed registry. The built-in library covers text manipulation, math, file I/O, typed collections, parallel execution, and a full lexer/parser framework; image handling is the registry's imaging package.
Downside Python's ecosystem is its defining advantage. NumPy, pandas, scikit-learn, TensorFlow, requests, Flask, SQLAlchemy, pytest, and hundreds of thousands of other packages are not available to loft programs, whose registry holds a few dozen — games and graphics first, with web, crypto, regex and markdown beside them. Data science, and most integration and protocol work, still means writing what Python gets from a single pip install. For these domains, loft is the wrong tool.
14. Exponentiation with ** or pow(); ^ is XOR
Loft
area = PI * r ** 2.0; // ** exponentiation (like Python)
bits = a | b; // bitwise OR
xor = a ^ b; // bitwise XOR — not exponentiation
cube = x ** 3; // integer cube — exact: 2 ** 10 == 1024
Python
import math
area = math.pi * r ** 2 # ** is exponentiation
bits = a | b # bitwise OR
xor = a ^ b # bitwise XOR
cube = x ** 3 # integer cube — exact
cube = x ** (1/3) # cube root as float
Upside Loft's ** exponentiation operator matches Python's — 2 ** 10 == 1024 on integers, 2.0 ** 3.0 == 8.0 on floats (pow(base, exp) is available too). ^ behaves as bitwise XOR in both loft and Python — no mismatch. Bitwise operator precedence matches: | (loose) → ^ → & → shifts → arithmetic (tight).
Downside Python's ** additionally handles complex numbers, and its three-argument pow(base, exp, mod) form computes modular exponentiation efficiently; loft has neither. Loft's pow() function itself operates on float/single only — use the ** operator for integer exponentiation.