Operators
Added in 0.4.2, every binary and unary operator in Dusk sits on one precedence ladder, thirteen levels from loosest to tightest. Each level is left associative unless noted, parentheses group as usual, and only the comparison level rejects chaining outright, so 1 < 2 < 3 is a compile error rather than a silently wrong bool. This page walks the whole ladder, then covers each operator family: bitwise, compound assignment, increment and decrement, exponent, pipe, and the inclusive range. For the types these operators run on, see Types, and for a gentler pass with runnable programs, see the language tour.
The precedence ladder
Section titled “The precedence ladder”| Level (loosest to tightest) | Operators | Notes |
|---|---|---|
| 1. Range | .. ..= | only legal inside a slice index |
| 2. Pipe | |> | a parse time rewrite to a call, see below |
| 3. Or | || | |
| 4. And | && | |
| 5. Comparison | == != < <= > >= | not chainable |
| 6. Bitwise or | | | |
| 7. Bitwise xor | ^ | |
| 8. Bitwise and | & | |
| 9. Shift | << >> | |
| 10. Additive | + - | |
| 11. Multiplicative | * / % | |
| 12. Exponent | ** | right associative |
| 13. Unary, then postfix | prefix - ! ~ *, then call, index, field | tightest, unary binds tighter than ** |
Shifts sit between & and +, and the bitwise trio nests | loosest, ^ in the middle, & tightest, so 4 | 2 ^ 3 & 1 groups as 4 | (2 ^ (3 & 1)) and 1 + 2 << 3 groups as (1 + 2) << 3. ** binds tighter than the multiplicatives and right associates, so 2 ** 3 ** 2 is 2 ** (3 ** 2), while unary minus binds tighter than ** at the call site, so -2 ** 2 is (-2) ** 2, which is 4.
Logical operators
Section titled “Logical operators”&& and || take a bool on each side, and as of 1.2.0 both short circuit. && evaluates its right operand only when the left is true; || only when the left is false. So a guard like i < n && a[i] == x never reaches the array read once the length check has already failed, and a right side that has a side effect, a call that prints or one that faults, does not run once the left side alone has settled the answer. Before 1.2.0 both operands always ran.
This is a change to evaluation order, not to typing. Both sides still type check no matter which one runs at a given call, so a non-bool operand on either side is a compile error, logical operators need bool operands, even behind a condition that would have skipped it.
Comparison
Section titled “Comparison”== != < <= > >= compare two operands of the same type, and they do not chain, so 1 < 2 < 3 is a compile error rather than a silently wrong bool. The comparable types are the scalars, an integer, a float, bool, char, and rune, plus string. A generic type parameter stays permissive on the surface pass, since its concrete shape is not yet known, and is checked again once monomorphization makes it ground.
A string supports only == and !=, and as of 1.2.0 those compare content: the bytes are compared at runtime, not the pointer identity every other value compares by. So a string built at runtime through str_from_chars, substring, or a StringBuilder buffer compares equal to an identical literal, and since a null pointer reads as the empty string, an error’s empty message compares equal to "". Strings have no ordering, so <, <=, >, and >= between two strings reject, strings compare with == and !=; they have no ordering. The + and += operators concatenate instead: a + b on two strings mints a fresh heap string you release with an ordinary free.
Several kinds have no meaningful comparison, and each rejects by name at the operator:
- A managed or raw pointer on either side:
pointers do not compare; compare the values they point to. - An
error:an error does not compare; test it with exists(). - An array, slice, tuple, struct, enum, interface, future, collector, or thread handle:
cannot compare <kind>; compare its parts instead, with<kind>the actual kind.
Compare a pointer by dereferencing both sides, an aggregate by comparing its parts, and an error with exists().
Floats and NaN
Section titled “Floats and NaN”A float comparison follows IEEE 754. ==, <, <=, >, and >= are ordered, so any comparison against NaN, on either side, answers false. != is the one unordered operator: x != y is the exact negation of x == y at every input, so NaN != x answers true for every x, NaN included. Every release through 1.3.1 shipped an ordered != that wrongly answered false on a NaN operand; 1.4.0 corrects it to the unordered form IEEE 754 defines. The ordered operators were already correct and are unchanged, and a comparison between two ordinary, non-NaN floats reads the same under either convention.
Bitwise operators
Section titled “Bitwise operators”&, |, and ^ are binary and ~ is unary, all on integer operands only, two’s complement throughout. ~0 is -1, -1 & 255 masks down to the low byte, and each width truncates the way ordinary arithmetic does, so an int8 operand keeps the mask honest at eight bits.
<< and >> shift by an integer amount. << is a plain logical shift; >> is always an arithmetic shift, sign extending the top bit, because Dusk does not track signedness separately from the type at the point a shift lowers. A constant shift amount outside [0, width) is a compile error, a negative constant included. A dynamic amount is checked at the shift itself, and a miss aborts with the named fault fatal: shift amount out of range, never a silently masked or poison result. This is the only kind of right shift Dusk needs, because it has no unsigned integer types in the first place: the uint8 through uint64 names are reserved rather than available, so every shift operand is one of the signed widths (see Types).
Compound assignment
Section titled “Compound assignment”+= -= *= /= %= &= |= ^= <<= >>= rewrite a place through a load, the operator, and a single store. The place, including any index expression, is evaluated exactly once: xs[pick()] += 5 calls pick() once even though it names the index. A compound assignment on an immutable binding is rejected, the same rule the plain = form follows, and mixing widths on the right is the same error the binary operator gives.
Increment and decrement
Section titled “Increment and decrement”++ and -- are statement only, postfix only, and produce no value; there is no prefix form and neither can appear inside an expression. Each desugars to a compound assignment with the literal 1, so i++ is i += 1 and an int8 place wraps exactly the way + 1 does.
Exponent
Section titled “Exponent”** is right associative. An integer base and exponent lower to cool_pow_i64, repeated squaring in uint64_t so the wraparound matches the plain mul codegen already emits; 0 ** 0 is 1, the same convention Rust’s pow uses. A negative integer exponent is meaningless for an integer result and aborts with the named fault fatal: negative exponent in integer '**' rather than returning a wrong value. A float base or exponent lowers to the LLVM pow intrinsic at the operand’s width.
x |> f(a) rewrites at parse time to f(x, a), prepending the left side as the call’s first argument; x |> f with a bare name becomes f(x). It is left associative and the loosest operator, so 1 + 2 |> double pipes the whole sum, not just the 2. The rewrite adds no capability, only a call spelling, so it is ungated by paradigm; a piped functional builtin still faces the ordinary paradigm gate on the call it rewrites to. The right side must be a function name or a call; anything else is a compile error naming the rule.
Inclusive range
Section titled “Inclusive range”a..=b in a slice index is a..b+1: the endpoint moves before the ordinary lo <= hi <= base.len bounds check runs, so xs[2..=1] is the empty slice rather than a trap, and xs[0..=n-1] covers the whole backing. The range operators are legal only inside a slice index, not as free standing values.
Numeric casts
Section titled “Numeric casts”int8(v), int16(v), int32(v), int64(v), char(v), rune(v), float32(v), and float64(v) convert one scalar value explicitly to the named type. The integer width cast arrived in 1.2.0 with five names and stopped at the integer family; 1.5.0 widened it to the whole numeric set, adding rune, float32, and float64 as targets and letting a cast cross the integer and float boundary in both directions. So the source may be an integer of any width, a char, a rune, a bool, a float32, or a float64, and a cast takes exactly one argument; any other count is int64(v) takes exactly one value.
What the conversion does depends on the operand and the target:
- Integer to integer runs through the same coercion an annotated widening or narrowing assignment already uses: two’s complement truncation going down, sign or zero extension going up, with
charandboolextending by magnitude rather than by sign.int32(300)fits and stays300,int8(300)reads44, andchar(101)is'e'. This much is unchanged from 1.2.0. - Integer to float reads the integer’s value as a float, exactly when it fits the significand, and again a
charor aboolreads as a magnitude.float64(65)is65. - Float to integer truncates toward zero, so
int64(3.9)is3andint64(-2.9)is-2. - Float to float widens a
float32to afloat64exactly and narrows afloat64to afloat32with rounding.
The float to integer direction is the one worth pausing on. A magnitude beyond the target’s range saturates to the nearest bound rather than wrapping around or going undefined, and a NaN casts to zero. C’s own conversion leaves an out of range input undefined, and Dusk makes it deterministic instead: the cast lowers through the saturating @llvm.fptosi.sat and @llvm.fptoui.sat intrinsics rather than a bare fptosi that would leave poison behind for exactly those inputs. So int64 of positive infinity is 9223372036854775807, int64 of negative infinity is -9223372036854775808, int8 of positive infinity clamps at 127, and int64 of a NaN is 0.
A cast is an unchecked numeric conversion, though, and rune(v) is where that bites. It accepts any 32 bit value, a negative one included, and one above U+10FFFF that no Unicode scalar carries. Nothing validates the result as a scalar, so a consumer that needs a valid one checks it itself.
A pointer, a string, a struct, or any other non scalar rejects by name, a numeric cast takes an integer, char, rune, or float value; string does not cast, with the type it was handed.
All eight names are reserved as builtin call forms, so a function cannot be declared with one of them, 'int32' is a primitive type name; a function cannot take it, since a call to the name would otherwise be ambiguous between the cast and the function. A variable or field named int32 is fine, and a local binding of the name shadows the cast in its own scope; only a function declaration collides. The hash builtin, added in 1.5.1, reserves its own name the same way, described in builtins.
@paradigm procedural
func main() -> int32 { small := int8(300) // truncates to eight bits, 44 println(small) // 44
wide := float64(65) // an integer reads as a float println(wide) // 65 println(int64(3.9)) // 3, truncated toward zero println(int64(-2.9)) // -2, toward zero, not down println(float64(float32(2.5))) // 2.5, a float32 widens back exactly
r := rune(66) // an integer to a codepoint, unchecked println(int64(r)) // 66
inf: float64 = 1.0 / 0.0 nan: float64 = 0.0 / 0.0 println(int64(inf)) // 9223372036854775807, saturated println(int8(inf)) // 127, saturated at the int8 bound println(int64(nan)) // 0, a NaN casts to zero return 0}break and continue
Section titled “break and continue”break and continue, added in 1.2.0, are statement keywords rather than plain identifiers, so a bare name break or continue is no longer legal where a name is expected. Each binds to the innermost enclosing loop, a while or a for: break exits it immediately, and continue skips the rest of the current body and jumps to the next iteration. In a for loop, continue still advances the index, so a skipped iteration moves on rather than stalling on the same element.
Both are gated to @paradigm procedural, the same gate the loop forms carry. Outside a procedural file each rejects, the 'break' statement requires the procedural paradigm; add '@paradigm procedural', and the continue form the same way. Used outside any loop, each is a compile error naming the rule: break is only legal inside a loop, continue is only legal inside a loop. A lambda body is its own function boundary and lends neither statement a target, even when the lambda runs inside a loop.
@paradigm procedural
func main() -> int32 { xs: int64[6] = [4, -1, 9, 200, -3, 7] mut sum: int64 = 0 for x in xs { if x < 0 { continue } // skip negatives, the index still advances if x > 100 { break } // stop at the first large value sum = sum + x } println(sum) // 4 + 9 = 13
wide := int8(300) // numeric cast, truncates to 44 println(wide) // 44 return 0}Operators Dusk does not have
Section titled “Operators Dusk does not have”A few operators common elsewhere are deliberately absent. The ternary ?:, optional chaining ?., and null coalescing ?? have no place in a language with no null: a managed pointer is single owner and every dereference is checked, a missing value is Maybe.None or an error, if already covers selection, and ? is reserved. There is no call-site spread operator, though ... now marks the tail of a variadic foreign function (see foreign functions). A dedicated string concatenation operator like <> or a reused ++ is absent as well, but the operation is not: + concatenates two strings as of 1.2.0, while StringBuilder still owns incremental building. A slice concatenation needs an allocator, which an operator has nowhere to name.
A quick tour of the set
Section titled “A quick tour of the set”@paradigm procedural
func main() -> int32 { mask := ~0 & 255 // bitwise and unary complement, low byte shifted := 1 << 4 // logical left shift, 16
mut n: int64 = 10 n += 5 // compound assignment, single store n++ // postfix increment, statement only
pow := 2 ** 10 // exponent, right associative, 1024
xs: int64[4] = [1, 2, 3, 4] s: int64[] = xs[0..=2] // inclusive range, xs[0], xs[1], xs[2]
println(mask) println(shifted) println(n) // 16 println(pow) println(s.len) // 3 return 0}