Skip to content

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.

Level (loosest to tightest)OperatorsNotes
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 postfixprefix - ! ~ *, then call, index, fieldtightest, 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.

&& 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.

== != < <= > >= 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().

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.

&, |, 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).

+= -= *= /= %= &= |= ^= <<= >>= 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.

++ 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.

** 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.

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.

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 char and bool extending by magnitude rather than by sign. int32(300) fits and stays 300, int8(300) reads 44, and char(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 char or a bool reads as a magnitude. float64(65) is 65.
  • Float to integer truncates toward zero, so int64(3.9) is 3 and int64(-2.9) is -2.
  • Float to float widens a float32 to a float64 exactly and narrows a float64 to a float32 with 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.

casts.dusk
@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, 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.

control-flow.dusk
@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
}

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.

operators.dusk
@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
}