Skip to content

§5 Expressions and Operators

Category Operators
Arithmetic + - * / %
Comparison == != < > <= >=
Logical && || !
Compound assignment += -= *= /=
Type-guard is
Borrow &expr &mut expr
Cast expr as Type

&& and || short-circuit. is tests whether an enum value is a specific variant (see §5 “Type-guard operator”) and returns bool.

Operator precedence: unary > multiplicative > additive > comparison > equality > && > || > assignment.

A call expression callee(args...) whose callee is a bare identifier is name-resolved at compile time. The identifier must resolve to one of:

  1. An in-scope binding — a parameter, a local let/let mut, a top-level fn or extern fn in the same module, or a name introduced by an import { … } specifier.
  2. A builtin in the language’s fixed call envelope: print, console, assert, spawn, sleep, serve, channel, send, receive, await, and the atomic intrinsics atomic_load, atomic_store, atomic_add, atomic_sub, atomic_cas, atomic_fence.
  3. An implicit runtime global — a top-level declaration of the prelude (runtime/prelude.sfn) or the runtime/sfn/** tree, which is linked into every binary and so is callable without an explicit import.
  4. An imported free function carried in the module’s localized import table.

A callee that resolves to none of the above is rejected:

error[E0420]: undefined function `notarealfunction`
--> app.sfn:2:5
|
2 | notarealfunction("hi");
| ^^^^^^^^^^^^^^^^
[kind: typecheck]

sfn check and the build path (sfn build/sfn run/sfn test) both exit non-zero on E0420. Defining the function — or importing it — clears the diagnostic:

fn greet() {}
fn main() {
greet(); // ok — resolves to the top-level declaration
}

Out of scope for E0420: member/method callees (obj.method()) are not name-resolved here — the member name is never flagged; undefined variable references (a bare identifier used as a value, not a callee) are a separate concern; and argument types are not checked by this rule.

A concrete synchronous top-level function, or a concrete synchronous nested / local function (a statement-position fn name(...) { ... } declared inside another function or lambda body), may be used without a wrapper where the surrounding expression supplies an exact fn(...) type:

fn double(x: int) -> int { return x * 2; }
fn apply(cb: fn (int) -> int, x: int) -> int { return cb(x); }
fn choose() -> fn (int) -> int { return double; }
let callback: fn (int) -> int = double;
let result = apply(double, 5);
fn wrap() -> fn (int) -> int {
fn triple(x: int) -> int { return x * 3; }
return triple; // a nested fn's bare name is equally eligible
}
struct Router {
handler: fn (int) -> int;
}
let router = Router { handler: double };
let routed = router.handler(5); // loads the pair and dispatches through it

Expected function types are supplied by typed variable initializers and assignments, call arguments, return positions, typed collection elements, and struct initializers. The parameter and return types must match exactly. A source effect row must be no broader than the expected row: for example, ![io.fs] may fill a ![io] slot, but the reverse is rejected. Calling the stored value imposes the effect row written in its fn(...) type. When a local let/parameter shadows a same-named nested fn, the shadowing binding wins at a value-position reference.

A normal double(5) call remains direct. A materialized value uses the uniform two-word closure representation described in the runtime ABI reference, with a null environment. Generic functions, async functions, and entry-point functions are not eligible — this applies equally to a top-level or a nested function (a generic nested fn is still rejected). This v0 path admits scalar and pointer parameter/return types only; aggregate signatures such as fn(string) -> string are rejected with E0840. Using a bare function name without an expected fn(...) type remains E0808. Imported/prelude function names are not yet eligible because their full callable signature proof does not cross the module boundary. A named function or lambda may be stored in a fn-typed struct field, and a member call such as router.handler(5) loads the two-word pair from that field and dispatches through the same closure-call seam. The pair is stored inline rather than heap-boxed separately. Fn-typed fields are recognized before ordinary struct-method fan-out, so a struct may carry both callable fields and methods without changing method resolution. A function value may also be stored in a collection element, but dispatch through that element is not yet shipped.

An anonymous function is written with a leading fn, a parenthesized parameter list, an optional -> ReturnType, and a body. The body is either a { ... } block or, for the additive expression-bodied short form, => expr:

let sq = fn(x: int) -> int { return x * x; }; // block form
let sq2 = fn(x) => x * x; // short form — equivalent
let add = fn(x: int, y: int) => x + y; // typed head, expr body
numbers.map(fn(x) => x * x); // the callback idiom

The short form is purely additive: fn(x) => expr desugars to a single-return block (fn(x) { return expr; }), so the two forms are equivalent and the block form is unchanged. The fn lead-in keeps lambda recognition zero-lookahead, so the body => never collides with the match-arm separator (Pattern => body, §8).

A lambda’s parameter and return types may be omitted when it is passed directly as a callback: the compiler infers them from the callee’s expected function type. For a user higher-order function whose parameter is declared fn(int) -> int, or for the builtin int[] methods .map / .filter / .reduce (§10), numbers.map(fn(x) => x * x) types x and the result from the mapper signature. A lambda that is already annotated keeps its annotations. (Inference is currently limited to these call-site positions and int-element arrays; other element types follow generic constraints.)

expr as Type performs an explicit numeric or pointer-shape conversion. The cast is postfix-bound (tighter than any binary operator) and left-associative — x as i32 as i64 parses as ((x as i32) as i64).

Source → Target LLVM op Notes
i8/i16/i32/i64 (signed) → wider int sext Sign-extends
u8/u16/u32/u64/usize (unsigned) → wider int zext Zero-extends
bool → wider int zext true → 1, not -1
int (any width) → narrower int trunc Truncates high bits, sign-independent
Signed int → float/double sitofp Signed integer to FP
Unsigned int → float/double uitofp Unsigned integer to FP
boolfloat/double uitofp true → 1.0, not -1.0
float/double → signed int llvm.fptosi.sat.* Truncates toward zero in range; clamps to the target bounds; NaN → 0
float/double → unsigned int llvm.fptoui.sat.* Truncates toward zero in range; clamps to the target bounds; NaN → 0
f32float (double) fpext FP widening
float (double) → f32 fptrunc FP narrowing
*T*U bitcast Pointer reinterpret
int*T inttoptr Address-from-integer
*Tint ptrtoint Address-as-integer
same → same identity No-op

Float-to-integer as casts are saturating, matching Rust: finite values are truncated toward zero and clamped to the target integer range, infinities clamp to the corresponding bound, and NaN converts to zero. The lowering uses LLVM’s llvm.fptosi.sat.* / llvm.fptoui.sat.* intrinsics rather than the bare conversion instructions, whose out-of-range result is poison.

expr as bool is rejected at the LLVM lowering level for any operand type — pointers, integers, and floats alike. The compiler emits a fix-it suggesting an explicit comparison: x != null for pointers, x != 0 for integers, x != 0.0 for floats. Reinterpreting bit patterns into i1 is almost always a bug; the comparison is the operation users actually want.

  • Integer-source signedness is recovered only for a plain identifier or parameter operand. Sailfin’s i8/u8/i16/u16/i32/u32/i64/u64/usize annotations all collapse to LLVM’s signless i8/i16/i32/i64, so the cast lowering recovers sign from the operand’s source-level annotation via its local/parameter binding: 255u8 as u64 now lowers as zext and produces 255 (SFN-503). A compound integer operands.field as u64, xs[i] as u64, f() as u64, (a + b) as u64 — carries no source annotation available to the lowering pass and still selects the signed forms (sext/sitofp), so an unsigned compound expression can still produce a sign-extended result. Float-to-integer casts do not share this limitation because the target annotation preserves its sign. Closing the integer-source gap needs the checker’s inferred type threaded through expression lowering, scoped to the typecheck slice of SFEP-0058 §3.3 (SFN-501).
  • Mixed int + float in a binary op (without an explicit cast) still silently widens to double — the architect-planned dominant_type tightening that would reject this rides on Slice E (number retirement). Workaround today: spell the cast (x as float + y or x + y as int). Tracked in issue #296.

>>, /, %, and the ordered comparisons (<, <=, >, >=) select their LLVM opcode from the operand’s declared signedness, recovered the same way as the cast lowering above — from the operand’s source-level i8/u8/…/usize annotation via its local or parameter binding:

Sailfin unsigned operands signed operands
a >> b lshr ashr
a / b udiv sdiv
a % b urem srem
a < b, <=, >, >= icmp ult/ule/ugt/uge icmp slt/sle/sgt/sge

==/!=, <<, &, |, ^ are sign-independent and lower the same way regardless of operand signedness. A shift’s count operand never affects opcode selection — shl/lshr/ashr all read it as an unsigned bit count; only the shifted value’s sign selects the opcode.

  • Source-level signedness is recovered only for a plain identifier or parameter operand, the same scope as the cast lowering above — a compound operand ((a + b) >> 4, xs[i] / 2, f() % 3) still selects the signed form. Closing that gap is the same inferred-type threading scoped to SFEP-0058 §3.3 (SFN-501).
  • A mixed-width pair (e.g. u32 compared against u64) keeps the signed form. The harmoniser sign-extends the narrower operand before the comparison runs, so the lowering deliberately falls back to the signed predicate rather than compare a sign-extended value as unsigned. Rejecting the width mix outright is SFN-501’s implicit-conversion work.
  • A mixed signed/unsigned pair keeps the signed form, for the same reason.
  • Widths covered: i8/u8, i32/u32, i64/u64. i16/u16 ordered comparisons still produce a pre-existing “unsupported comparison operator” diagnostic, unrelated to this fix.

expr is Variant tests whether a named enum value is a specific variant. It is an infix binary operator with the same precedence as comparison operators, and it returns bool.

enum Input {
Text { value: string },
Number { amount: int }
}
fn process(input: Input) ![io] {
if input is Text {
print("It's a string: ${input.value}");
} else {
print("It's a number: ${input.amount}");
}
}
  • Discriminant test. x is Variant lowers to the enum’s discriminant tag comparison — the same check match uses for an enum-variant arm.
  • Flow-sensitive narrowing (then-branch). Inside the if-is then-branch the compiler narrows the operand’s type to the tested variant. Member accesses against the narrowed binding (input.value above) resolve against that variant’s payload fields — the same narrowing match applies to a destructured arm.
  • Effect transparency. The effect checker walks the operand of is. If the operand expression itself requires effects (e.g. readFile() is T), those effects must be declared on the enclosing function. The operator adds no effects of its own.
  • Returns bool. The result is an ordinary boolean expression usable in any boolean context — if, &&/||, let b = x is Variant, etc.

The current implementation supports is on named enum operands only. The following are not yet supported and are deferred:

  • Non-enum operands: inline union types (string | int), primitives, and plain structs.
  • Else-branch complement narrowing: the else-branch does not narrow the operand to the complement set of variants.

These limitations exist because the typecheck pass does not yet perform general expression-type inference (#829), and the lowering’s narrowing machinery is enum-discriminant-based. Non-enum narrowing will be addressed when expression-type inference lands.

The worked example is examples/advanced/type-guards.sfn.