§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.
Function calls and name resolution
Section titled “Function calls and name resolution”A call expression callee(args...) whose callee is a bare identifier is name-resolved at compile time. The identifier must resolve to one of:
- An in-scope binding — a parameter, a local
let/let mut, a top-levelfnorextern fnin the same module, or a name introduced by animport { … }specifier. - A builtin in the language’s fixed call envelope:
print,console,assert,spawn,sleep,serve,channel,send,receive,await, and the atomic intrinsicsatomic_load,atomic_store,atomic_add,atomic_sub,atomic_cas,atomic_fence. - An implicit runtime global — a top-level declaration of the prelude (
runtime/prelude.sfn) or theruntime/sfn/**tree, which is linked into every binary and so is callable without an explicitimport. - 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.
Named function values
Section titled “Named function values”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 itExpected 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.
Anonymous functions (lambdas)
Section titled “Anonymous functions (lambdas)”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 formlet sq2 = fn(x) => x * x; // short form — equivalentlet add = fn(x: int, y: int) => x + y; // typed head, expr bodynumbers.map(fn(x) => x * x); // the callback idiomThe 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.)
Cast operator (as)
Section titled “Cast operator (as)”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).
Numeric cast pairs
Section titled “Numeric cast pairs”| 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 |
bool → float/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 |
f32 → float (double) |
fpext |
FP widening |
float (double) → f32 |
fptrunc |
FP narrowing |
*T → *U |
bitcast |
Pointer reinterpret |
int → *T |
inttoptr |
Address-from-integer |
*T → int |
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.
Rejected casts
Section titled “Rejected casts”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.
Known limitations (Slice D, 2026-05-03)
Section titled “Known limitations (Slice D, 2026-05-03)”- Integer-source signedness is recovered only for a plain identifier or parameter operand. Sailfin’s
i8/u8/i16/u16/i32/u32/i64/u64/usizeannotations all collapse to LLVM’s signlessi8/i16/i32/i64, so the cast lowering recovers sign from the operand’s source-level annotation via its local/parameter binding:255u8 as u64now lowers aszextand produces255(SFN-503). A compound integer operand —s.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+floatin a binary op (without an explicit cast) still silently widens todouble— the architect-planneddominant_typetightening that would reject this rides on Slice E (numberretirement). Workaround today: spell the cast (x as float + yorx + y as int). Tracked in issue #296.
Sign-sensitive binary operators
Section titled “Sign-sensitive binary operators”>>, /, %, 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.
Known limitations (SFN-573)
Section titled “Known limitations (SFN-573)”- 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.
u32compared againstu64) 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/u16ordered comparisons still produce a pre-existing “unsupported comparison operator” diagnostic, unrelated to this fix.
Type-guard operator (is)
Section titled “Type-guard operator (is)”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}"); }}Semantics
Section titled “Semantics”- Discriminant test.
x is Variantlowers to the enum’s discriminant tag comparison — the same checkmatchuses for an enum-variant arm. - Flow-sensitive narrowing (then-branch). Inside the
if-isthen-branch the compiler narrows the operand’s type to the tested variant. Member accesses against the narrowed binding (input.valueabove) resolve against that variant’s payload fields — the same narrowingmatchapplies 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.
v1 scope and limitations
Section titled “v1 scope and limitations”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.