Operators And Evaluation
This chapter documents Zynx expression evaluation order, operator precedence, and contextual enum literals.
Status: current public API unless a rule is marked unsupported or unspecified.
Expressions And Evaluation Order
Zynx has literals, names, calls, member access, optional member access, subscript expressions, struct literals, array literals, tuple literals, closures, unary and binary operators, casts, match, try, catch, and await.
Evaluation order is part of the language contract. Except for constructs that explicitly short-circuit or select a branch, subexpressions are evaluated left-to-right in source order, and the side effects of each subexpression are complete before the next subexpression begins.
The specified evaluation-order rules are:
- Unary operators evaluate their operand before applying the operator. Casts evaluate the value before performing the checked conversion.
- Binary operators evaluate the left operand before the right operand.
&&evaluates its scalar-boolleft operand first and evaluates its scalar-boolright operand only when the left operand istrue.||evaluates its scalar-boolleft operand first and evaluates its scalar-boolright operand only when the left operand isfalse.- A call evaluates the callee expression first. A method call evaluates the receiver before all explicit arguments.
- Call arguments are evaluated in parameter order after named and default arguments are normalized. A named argument is evaluated when its parameter slot is reached. A default argument expression is evaluated at the call site when its parameter slot is reached. Names inside a default argument bind in the declaration environment of the function, method, or constructor: the declaring module scope plus any declaration generic parameters. Defaults do not bind in the caller's scope and cannot reference earlier parameters from the same parameter list. A throwing default expression must be permitted by the function's declared
throws(...)effect, and omitting that argument has the same static call effect as the call itself. Named arguments must follow parameter order. - Struct literal fields are matched by name and evaluated in struct declaration order, including any default field values. Array, vector, and tuple literal elements are evaluated left-to-right.
- Member access, optional member access, and subscript expressions evaluate the receiver or base expression first. Subscript index expressions are evaluated left-to-right after the base expression.
lhs = rhsevaluates the assignment target first, including the receiver, base, member path, and index expressions needed to identify the destination. It then evaluatesrhsand stores the result. Compound assignments evaluate the target, load the old value, evaluate the right operand, compute the new value, and store it.- A declaration initializer evaluates its right-hand expression before the binding is initialized.
- Tuple destructuring evaluates the right-hand tuple expression once, then initializes bindings left-to-right from the tuple elements.
deferregisters its body when control reaches thedeferstatement. When a block exits normally, defers registered in that block run in last-in, first-out order before automatic cleanups for locals whose lifetime ends at that block. Those automatic cleanups run in reverse cleanup-registration order.return,break,continue, and error propagation unwind exited scopes with the same defer-before-cleanup ordering before transferring control.try exprevaluatesexpr, unwraps success, and returns the error from the current function on failure.expr catch { ... }evaluatesexpr; on success it yields the unwrapped value, and on error it evaluates the matching catch arm.await exprevaluatesexpr, consumes the resulting task handle, waits for completion, and yields the task result.@select(mask, when_true, when_false)requires an exact Vector or Matrix bool mask. It evaluates the mask and both branches exactly once, left-to-right, before eagerly selecting lanes.m[r] = rhsevaluates the Matrix base, evaluates and bounds-checksr, then evaluatesrhsand stores one complete row.m[r] op= rhsadditionally snapshots the old row before evaluatingrhsand performs one final store only after the complete checked result succeeds.m[r][c]checks the row before evaluating and checking the column.
Operator Precedence
This table defines how expressions are grouped when parentheses are omitted. Higher precedence binds more tightly. Prefix forms and parser-only await parse their operand at the next tighter level, so -x as T, try f() as T, and await f() as T group as (-x) as T, (try f()) as T, and (await f()) as T.
| Precedence | Operators and forms | Grouping |
|---|---|---|
| 13 | (), [], ., ?. | postfix/member, left-to-right |
| 12 | prefix !, ~, +, -, *, &, <-, try, await; as | prefix forms parse their operand at level 13; as is left-to-right and takes a type on the right |
| 11 | *, /, % | left |
| 10 | ..., ..< | left |
| 9 | +, - | left |
| 8 | <<, >> | left |
| 7 | <, >, <=, >= | left |
| 6 | ==, != | left |
| 5 | & | left |
| 4 | ^ | left |
| 3 | | | left |
| 2 | && | left, short-circuit |
| 1 | ||, catch | left |
| 0 | =, <-, +=, -=, *=, /=, %=, <<=, >>=, &=, |=, ^= | right |
catch is followed by a catch-arm block, not a normal expression operand. Because || and catch share the lowest non-assignment precedence, parenthesize expressions that mix them unless this grouping is intended.
Examples:
a + b * cgroups asa + (b * c).a + b..<cgroups asa + (b..<c); write(a + b)..<cfor an arithmetic lower bound.-x as i32groups as(-x) as i32.&x as *i32groups as(&x) as *i32.await async.timeout(child, d) catch { _ => fallback }groups as(await async.timeout(child, d)) catch { _ => fallback }.
Integer Right Shift
For i8, i16, i32, i64, i128, and isize, left >> count performs arithmetic right shift with sign extension. Its value equals mathematical floor division of left by 2^count, so (-3 as i32) >> 1 is -2. For u8, u16, u32, u64, u128, and usize, it performs logical right shift and fills the high bits with zero. Signedness of the exact left operand type selects the operation; neither the count type nor the expected result changes it.
The count rule is shared with <<: a negative runtime count or one greater than or equal to the left operand's bit width traps. The corresponding invalid constant expression is rejected at compile time. Scalar, Vector-lane, Matrix element, constant, and runtime evaluation use the same result; host C signed >> is not the language definition.
Boolean Operators
Both operands of && and || must have type exactly scalar bool, and their result is scalar bool. Integers, pointers, nullable values, bool masks, enums, aggregates, and other values have no truthiness and do not convert implicitly to bool. Write the intended test explicitly, for example count != 0 or ptr != null.
&& and || evaluate left-to-right and short-circuit as specified above. Unary ! accepts scalar bool, Vector<bool, N>, and Matrix<bool, R, C>. It evaluates its operand once. A scalar result is the negated scalar bool; a mask result eagerly negates every lane and preserves the operand's exact shape. Integers, pointers, nullable values, and other non-bool scalar operands are rejected. if, while, and the first operand of assert still require scalar bool; reduce a mask explicitly with @all(mask) or @any(mask).
@all and @any accept only exact Vector<bool, N> and Matrix<bool, R, C> masks and return scalar bool. @all(mask) is true only when every lane is true; @any(mask) is true when at least one lane is true. A scalar bool is already a condition and must be used directly: @all(true) and @any(false) are rejected. Numeric and other scalars, non-bool Vectors and Matrices, arrays, tuples, structs, and user-defined aggregates are also rejected. There is no shape-polymorphic reduction protocol; these two intrinsics are the sole explicit conversion from an aggregate bool mask to a scalar condition.
@select(selector, when_true, when_false) accepts only an exact Vector<bool, N> or Matrix<bool, R, C> selector. Scalar bool has no selector overload: @select(true, a, b) and @select(false, a, b) are rejected. Use a value match with expression arms for scalar value selection, or statement if for assignment and control flow. A future constant-time scalar selection operation requires its own explicit API and contract.
Selection is eager: the selector, when_true, and when_false expressions are evaluated exactly once in that left-to-right order before lane-wise selection. For the selector's exact Vector or Matrix kind and shape, each data branch independently may be either an aggregate of that same kind and shape with element type T, or a scalar exactly T that is splatted to every lane. Both branches must resolve independently to the same exact T. Aggregate/aggregate, aggregate/scalar, scalar/aggregate, and scalar/scalar combinations are legal; the result has the selector's exact kind and shape with element type T.
There is no numeric promotion, implicit conversion, cross-kind/shape aggregate, or contextual coercion from the sibling branch or expected result. Literals keep their normal literal/default types. For example, selecting between a Vector<i64, N> and 0 is rejected when 0 defaults to i32; write 0 as i64 or use a typed local. This branch splatting is local to @select and does not add scalar broadcast to bool-mask algebra; numeric Vector arithmetic has the separate closed rule below.
Void Equality
void supports only equality. Both operands are evaluated normally; void() == void() is always true, and void() != void() is always false. It has no ordering, arithmetic, bitwise, or shift operators. In particular, zero representation size does not introduce pointer or representation equality.
Payload Enum Equality
== on a payload enum compares values structurally rather than comparing the enum's complete memory representation. It evaluates the left operand exactly once, then the right operand exactly once, using shared read access. Tags are compared first. Different variants produce false without inspecting payload storage. Matching variants compare only their active payload fields, in declaration order, and stop at the first field whose ordinary == returns false. Matching payloadless variants are equal. != is exactly the logical negation of this complete result.
enum Shape {
Origin,
Circle(radius: f64),
Rect(width: f64, height: f64),
}
let a = Shape.Rect { width: 3.0, height: 4.0 };
let b = Shape.Rect { width: 3.0, height: 5.0 };
let same = a == b; // false after tag, width, then height
let zero_a = Shape.Circle { radius: -0.0 };
let zero_b = Shape.Circle { radius: 0.0 };
let same_zero = zero_a == zero_b; // true by IEEE float equalityEquality is available for the enum type only when every payload field of every variant has ordinary == returning exact scalar bool. The check covers inactive variants too. For example, an enum containing a Vector<i32, 4> payload is ineligible because Vector equality returns a bool mask, so even comparing two payloadless variants of that enum is a hard error. The diagnostic identifies a blocking variant and field. No runtime variant can bypass this whole-type check.
enum Mixed {
Empty,
Lanes(value: Vector<i32, 4>),
}
let invalid = Mixed.Empty == Mixed.Empty;
// error: Mixed.Lanes.value equality returns Vector<bool, 4>, expected boolThe operator does not move or copy operands or fields, alter cleanup obligations, allocate, or introduce checked errors. Nested enum fields apply the same rule recursively. Float fields retain IEEE behavior: signed zeros compare equal and NaN does not equal itself, making enum != true for a matching variant containing NaN. Padding and bytes belonging to inactive variants are never read as values. A compiler may select memcmp only after proving it equivalent to these results for the exact enum type; it is not the definition of ==.
There is no Eq marker and user code cannot overload ==. In a generic body, payload-field equality must be proved from the declared bounds when the body is checked; it cannot be postponed until instantiation. A concrete instantiated enum may be checked after its payload types are substituted.
Vector, Matrix, And Mask Operators
Vector arithmetic +, -, *, /, and % is lane-wise. For numeric Vector<T, N>, the other operand may be another exact Vector<T, N> or a scalar exactly T, with the scalar accepted on either side and splatted across the Vector shape without materializing a Vector aggregate. Each operand is typed independently: there is no promotion, implicit conversion, coercion from the sibling operand, or coercion from the expected result. Literals keep their normal defaults, so Vector<i64, N> + 1 is rejected unless the scalar is made i64, for example with 1 as i64.
Both operands evaluate exactly once, left-to-right. Lane computation preserves their written order for -, /, and %. Integer lanes use the corresponding checked scalar semantics, including overflow, divide-by-zero, and signed- minimum with -1 traps; floating-point lanes use scalar floating-point semantics. This scalar rule does not extend to Vector comparisons, bitwise, shift, or mask operators, nor to any Matrix operator.
Matrix +, -, /, %, and integer bitwise/shift operations are element-wise. Matrix * means linear multiplication for Matrix/Matrix, Matrix/Vector, and Vector/Matrix, and scaling when exactly one operand is a scalar of the Matrix element type. @hadamard(a, b) is the equal-shape element-wise Matrix product. Compound assignment is legal only when the operator's complete result type is exactly the left-hand type.
Aggregate comparisons return same-shape bool masks. Masks support !, &, |, ^, indexing, row projection, transpose, formatting, @all, @any, and @select. Mask equality and inequality are themselves lane-wise and return a mask. Binary &, |, ^, ==, and != require two exact masks of the same kind and shape: matching Vector<bool, N> operands or matching Matrix<bool, R, C> operands. Each evaluates the left operand exactly once, then the right operand exactly once, and eagerly applies the operation to every lane. The result preserves that exact mask type and shape. Unary ! has the same eager, same-shape behavior.
There is no scalar broadcast, Vector/Matrix mixing, or cross-shape mask operation. Arithmetic and ordering over masks, numeric operations, ~, &&, and || are rejected. The short-circuit operators remain exclusive to scalar bool. Use @all(a == b) for whole-mask equality, @any(a != b) for whole-mask inequality, and @all(mask) or @any(mask) whenever a scalar condition is required.
Checked integer linear multiplication validates every product and every accumulation step in increasing inner-dimension order. A trap prevents the operation's destination from being partially updated.
Optional Access, Ranges, And Slices
?. is postfix member access for nullable receivers. It evaluates the receiver once; if that value is null, the expression yields null, otherwise the field is read or the method is called on the payload. The result type is nullable even when the member result itself is not nullable. Every nullable step must use ?.; x?.y.z still uses ordinary . after y.
The resolved receiver type of every ?. must be T?. A non-nullable T receiver is a hard compile error for field and method access alike; write . instead. Neither an expected nullable result nor the ordinary contextual T to T? result injection wraps a receiver to make ?. legal. Such result injection may instead apply outside an ordinary access, as in let result: U? = value.member;. Each written ?. therefore denotes a real null branch and evaluates its receiver exactly once. Its present path retains the same ownership, move, loan, and lifetime rules as ordinary member access.
The range operators ..< and ... build first-class range values. In a subscript, a range operand returns a slice view instead of an element: items[lo..<hi], items[lo...hi], and items[range] all borrow from items. Exclusive slices stop before hi; inclusive slices include hi. Negative integer bounds count back from the end, so items[1..<-1] excludes the first and last element.
Index and slice bounds failures are runtime traps, not recoverable throws(...) errors. Slice start and end positions must land inside 0..length, with length..<length allowed as an empty slice.
Enum Literals
Fieldless enum variants are constructed as Enum.Variant or, when an expected enum type is available, as .Variant.
Payload variants declare their payload fields with parameter-list syntax in the enum declaration, but they are constructed with named struct-literal syntax:
enum Shape {
Circle(radius: f64),
Rect(width: f64, height: f64),
}
let a: Shape = Shape.Circle { radius: 2.0 };
let b: Shape = .Rect { width: 3.0, height: 4.0 };The constructor must initialize every payload field exactly once by name. Shape.Circle(2.0) is rejected with a suggestion to use Shape.Circle { radius: ... }; fieldless variants are values, so Shape.Done() is rejected with a suggestion to use Shape.Done. Bare Shape.Circle or .Circle for a payload variant is rejected because the payload fields are missing. A contextual payload literal such as .Rect { ... } is accepted only when the expected type is a single enum type from assignment, return, argument, field, array/vector, tuple, match-arm, or overload context. If no enum type can be inferred, write the qualified form.
Struct Literals
A struct value is written either qualified with its type name or in inferred form, where the type is taken from the surrounding context:
struct Point { let x: i32, let y: i32 }
let a = Point { x: 1, y: 2 }; // qualified
let b: Point = { x: 1, y: 2 }; // inferred from the declared typeThe inferred { ... } form is accepted wherever a single struct type is known from context: a typed let or var binding, an assignment, a return, a function argument, or a field initializer (including nested, e.g. Outer { a: { v: 5 } }). With no such context — let p = { x: 1 }; — the type cannot be inferred and is an error; add a type annotation or write the qualified form. The leading-dot form .Point { ... } is not a struct literal (the leading dot is reserved for enum variants); use { ... } or Point { ... }.
Struct literals initialize fields directly; they do not call init. Fields are matched by name and evaluated in struct declaration order, including field defaults. Every required field must be initialized exactly once; unknown, duplicate, missing, and mismatched fields are rejected. Constructor calls use Point(...) syntax and resolve to init.
A struct or tuple literal may be passed directly to a bare shared parameter; its temporary lives for the call. Address-of otherwise requires a stable place rooted in named storage, but a fresh inline struct literal may also be passed directly to a mutable &Struct parameter. Both inferred and qualified struct literals are accepted in that position:
fn sum(p: Point) -> i32 { return p.x + p.y; }
fn adjust(p: &Point) { p.x = p.x + 1; }
let s = sum(Point { x: 3, y: 4 });
adjust(&{ x: 3, y: 4 });
adjust(&Point { x: 3, y: 4 });The exclusive temporary lives through the full expression and is then destroyed. The callee may mutate it. Its loan, including any loan derived by the callee, may be consumed by another call in that same full expression, but it cannot be bound, stored, returned, captured, or otherwise escape. & does not generalize this exception to scalar, tuple, array, call-result, operator-result, or other arbitrary rvalues:
fn bump(value: &i32) { *value = *value + 1; }
bump(&(1 + 2)); // rejected: arbitrary rvalue
let bad: &Point in _ = &{ x: 1, y: 2 }; // rejected: not a direct argumentFor adjust(&{ ... }), overload resolution must select adjust and its &Point parameter without using the inferred literal. Only then does the parameter supply the literal's contextual type. Thus an inferred inline literal cannot choose between overloads. &Point { ... } is different only in that the qualified literal already has its own type and can participate in ordinary overload resolution; it has the same direct-argument and lifetime restrictions.