Operators
Arithmetic Operators #
| Operator | Description | Example |
|---|---|---|
+ |
Addition | 3 + 2 → 5 |
- |
Subtraction | 10 - 4 → 6 |
* |
Multiplication | 3 * 4 → 12 |
/ |
Division | 10 / 3 → 3 |
% |
Modulo | 10 % 3 → 1 |
~/ |
Floor division | 7 ~/ 2 → 3 |
** |
Exponentiation | 2 ** 8 → 256 |
Floor Division ~/
~/ performs floor division (Lua // semantics): the result is always rounded toward −∞ (negative infinity), not toward zero.
print(7 ~/ 2) // 3
print(-7 ~/ 2) // -4 (floor toward −∞, not −3)
print(7 ~/ -2) // -4
print(7.5 ~/ 2) // 3 (float operand → floored quotient)
Type rules:
int ~/ int→int(floor toward −∞, sign-corrected)float ~/ *or* ~/ float→float(floor of the quotient)int ~/ 0→ runtime error ("attempt to perform 'n~/0'")float ~/ 0→ follows IEEE 754 (±inf/nan), thenflooris applied
~/ is not the same as truncating division (/ truncates toward zero for floats; ~/ floors):
print(-7 ~/ 2) // -4 (floor) ← ~/
// vs. -7 / 2 in most languages → -3 (truncate)
Compound assignment ~/=:
var x = 20
x ~/= 6 // x == 3
Note:
~/does not conflict with the//line-comment token. Zolo detects//as a comment during lexing before operator tokenisation;~/is a distinct two-character token (~followed by/).
Comparison Operators #
| Operator | Description | Example |
|---|---|---|
== |
Equal | x == 5 |
!= |
Not equal | x != 0 |
< |
Less than | x < 10 |
> |
Greater than | x > 0 |
<= |
Less or equal | x <= 100 |
>= |
Greater or equal | x >= 1 |
~= |
Approximate equal (float) | 0.1 + 0.2 ~= 0.3 |
!~= |
Approximate not equal (float) | a !~= b within 1e-9 |
Approximate Equality (~= and !~=)
Direct == on float values is unreliable due to binary floating-point representation. Zolo's float-equality lint flags this and suggests ~= instead.
~= supports three within clauses to control the tolerance:
// Bare form — adaptive tolerance (default, covers ~80% of cases)
print(0.1 + 0.2 ~= 0.3) // true
// Absolute tolerance: |a - b| <= tol
print(velocity ~= 0.0 within 1e-6) // true if velocity < 0.000001
// Relative tolerance: |a - b| / max(|a|, |b|) <= rtol
print(1e10 ~= 1.0000001e10 within 0.001 relative) // true (0.000001% < 0.1%)
// ULP tolerance: bit-pattern distance <= n (for testing math functions)
print((0.1 + 0.2) ~= 0.3 within 1 ulps) // true (1 ULP of noise)
!~= is the exact negation of ~= and accepts all the same within clauses:
// Loop until convergence
while prev !~= x within 1e-10 {
prev = x
x = next_iteration(x)
}
For the complete reference — decision tree, all tolerance modes, NaN/infinity handling, decimal and bigdecimal types, and the float-equality lint — see Float Precision & Decimal Types.
Logical Operators #
| Operator | Description | Example |
|---|---|---|
&& |
Logical AND | a && b |
|| |
Logical OR | a || b |
! |
Logical NOT | !flag |
Assignment Operators #
| Operator | Description | Example |
|---|---|---|
= |
Assignment | x = 10 |
+= |
Add and assign | x += 5 |
-= |
Subtract and assign | x -= 3 |
*= |
Multiply and assign | x *= 2 |
/= |
Divide and assign | x /= 4 |
%= |
Modulo and assign | x %= 3 |
~/= |
Floor-divide and assign | x ~/= 3 |
Pipe Operator |>
The pipe operator passes the left expression as the first argument to the right function. This creates a natural top-to-bottom data flow:
// Without pipe — hard to read (inside-out)
let result = collect(filter(map(list, |x| x * 2), |x| x > 5))
// With pipe — natural flow
let result = list
|> map(|x| x * 2)
|> filter(|x| x > 5)
|> collect()
How It Works #
a |> f(b, c) transforms to f(a, b, c) — the left side becomes the first argument.
" Hello World "
|> string.trim()
|> string.split(" ")
|> Array.map(|s| string.to_upper(s))
|> Array.join(", ")
Pipe with Placeholder _
When the piped value shouldn't be the first argument:
users
|> sort(_, by: .name)
|> group(_, key: |u| u.age)
Tap Operator &.
Executes a side effect without breaking the chain — the original value passes through unchanged. Perfect for debugging and logging:
list
|> sort()
&. print() // prints the sorted list, passes it along
|> filter(|x| x > 0)
&. |x| log(x) // debug log
|> collect()
How It Works #
a &. f() calls f(a) for its side effect, then returns a unchanged.
Optional Chaining ?.
Safely access fields on values that might be nil:
let city = user?.address?.city // nil if any part is nil
Without optional chaining, you'd need nested nil checks:
// Equivalent without ?.
let city = if user != nil {
if user.address != nil {
user.address.city
} else { nil }
} else { nil }
Force Chain !.
Force-unwrap a Result or Option, then immediately access a field or call a method. Panics if the value is Err or None, exactly like .unwrap():
let name = Result.Ok(user)!.name // unwrap, then read .name; panics if Err
let up = parse(s)!.to_upper() // unwrap then call .to_upper()
a!.field is equivalent to a.unwrap().field; a!.method() is equivalent to a.unwrap().method().
Contrast with ?. and ?>
| Operator | On Err/None |
On Ok/Some |
|---|---|---|
?. |
Returns nil (null-safe) |
Accesses field/method |
!. |
Panics (force-unwrap) | Accesses field/method |
?> |
Propagates out of fn | Pipes unwrapped value |
Use !. only when you are certain the value cannot be an error — in tests, quick scripts, or after a prior validation. For safe access, prefer ?. (null-safe) or ?> (propagate).
Note:
!.is currently supported on the VM backend. Native (Cranelift) support is planned.
Null Coalesce ??
Provide a default value when the left side is nil:
let name = user?.name ?? "Anonymous"
let port = config?.port ?? 8080
Combining with Optional Chaining #
let city = user?.address?.city ?? "unknown"
Error Propagation ?
Propagates errors up the call stack, similar to Rust:
fn read_config(path: str) -> Result<Config, Error> {
let text = fs.read(path)? // returns early on error
let config = json.parse(text)?
Result.Ok(config)
}
When ? is applied to a Result.Err, the function immediately returns that error. When applied to Result.Ok(value), it unwraps to value.
Fallible Pipe ?>
a ?> rhs is the propagate-then-pipe operator — the chainable sibling of ?. It combines error propagation with piping in a single step:
- If
aisResult.ErrorOption.None, it propagates out of the enclosing function (exactly like?). - If
aisResult.Ok(v)orOption.Some(v), it pipes the unwrapped valuevintorhs(exactly like|>).
fn list_users(db) -> Result {
db.query("SELECT * FROM users") ?> .each(|u| print(u.name))
Result.Ok(0)
}
The dot-method form a ?> .method(args) is the most common use: it unwraps the result and calls .method() on the inner value without a separate ? + |> step.
Propagation position #
Propagation happens at statement and let-binding position (the enclosing function returns the error). Inside a nested sub-expression, ?> performs a best-effort soft-unwrap instead of a hard early-return.
fn process(items) -> Result {
let count = get_items()? |> Array.len() // two steps: ? then |>
get_items() ?> .each(|x| print(x)) // one step: ?> does both
Result.Ok(count)
}
?>is to|>what?is to an ordinary expression — it adds fallibility to the pipe chain without extra syntax.
Spread Operator ...
Spread elements into arrays or structs:
let a = [1, 2, 3]
let b = [0, ...a, 4, 5] // [0, 1, 2, 3, 4, 5]
let base = { name: "Alice" }
let full = { ...base, age: 30 }
Range Operators #
| Operator | Description | Example |
|---|---|---|
.. |
Exclusive range | 0..10 → 0 to 9 |
..= |
Inclusive range | 0..=10 → 0 to 10 |
for i in 0..5 { // 0, 1, 2, 3, 4
print(i)
}
for i in 0..=5 { // 0, 1, 2, 3, 4, 5
print(i)
}
Operator Precedence #
From highest to lowest:
- Postfix / access:
.,?.(null-safe chain),!.(force-unwrap chain),?(error propagate) - Unary:
-x,!x - Exponentiation:
** - Multiplicative:
*,/,%,~/ - Additive:
+,- - Range:
..,..= - Comparison:
<,>,<=,>= - Equality:
==,!= - Logical AND:
&& - Logical OR:
|| - Null coalesce:
?? - Pipe:
|>— Fallible pipe?>(same precedence as|>) - Tap:
&. - Assignment:
=,+=,-=,*=,/=,%=,~/=