Skip to content

Operators

Added in 0.4.2, every binary and unary operator in Dusk sits on one precedence ladder, fourteen 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: the conditional, 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. Conditional? :since 1.13.3, right associative, see below
4. Or||
5. And&&
6. Comparison== != < <= > >=not chainable
7. Bitwise or|
8. Bitwise xor^
9. Bitwise and&
10. Shift<< >>
11. Additive+ -
12. Multiplicative* / %
13. Exponent**right associative
14. 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.

c ? a : b, added in 1.13.3, is the ternary conditional: it evaluates c, a bool, and yields a when it is true and b when it is false, running only the branch it takes. It is the loosest operator on the ladder below the pipe, above assignment, and right associative, so a ? b : c ? d : e reads as a ? b : (c ? d : e), a chain of tests taken in order. Both branches are required: a ? with no : after its first branch is a conditional needs a ':' and a second branch, as in c ? a : b. The : never collides with a type annotation’s colon or a struct literal’s field colon, since it is read only once the parser is already inside a ?.

The ternary is the operator spelling of the if expression: the parser builds the same node, so a ?: and an if c { a } else { b } share one type rule, one ownership verdict, and one escape judgment, and neither spelling accepts a shape the other refuses. The branches must agree on type under the same rules a match expression’s arms follow, with a bare literal adapting to the branch beside it. Reach for the ternary where the branches are single expressions and the if form where a branch wants a block.

ternary.dusk
func main() -> int32 {
n: int64 = 7
label := n % 2 == 0 ? "even" : "odd"
println(label) // odd
grade := n >= 90 ? "A" : n >= 80 ? "B" : "C"
println(grade) // C
return 0
}

== != < <= > >= 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 for both signednesses. >> follows the operand’s own type, since 1.13.0 made the unsigned widths real: on a signed operand it is an arithmetic shift that sign extends the top bit, and on an unsigned one it is a logical shift that brings a zero down into the top bit rather than a copy of the sign. 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.

+= -= *= /= %= &= |= ^= <<= >>= 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 -- step a place by one. They arrived as statements in 0.4.2, and since 1.13.3 they are expressions in both a prefix and a postfix form, legal anywhere an expression is: arr[idx++], arr[++idx], f(i++), return ++n, an array element, or a struct literal field. Postfix yields the value the place held before the step, and prefix the value it holds after, so with i at 1 the expression i++ + i++ is 1 + 2, which is 3, and leaves i at 3; evaluation inside one expression is left to right and fully defined, never left to the backend.

The operand is a place whose binding is mut, the same rule and messages compound assignment carries, and the place is evaluated exactly once, so xs[pick()]++ calls pick() once and the read and the store share the index it returned. The operand is an integer of any width, signed or unsigned, wrapping at its own width the way + 1 already wraps, so -- on an unsigned zero wraps to the top rather than going below it. A float, a char, a rune, a bool, and a pointer are each refused with the operator, the type, and the verb in one line, '++' takes an integer place; float64 cannot be incremented. A non place is '++' needs a place to write back to: a name, a field, an index, or a dereference, and an immutable place earns the same message the assignment forms give. Written as a plain statement the stepped value is simply discarded, with no unused value complaint, so n++ on its own line keeps the meaning it always had.

** 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), uint8(v), uint16(v), uint32(v), uint64(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; 1.13.0 added the four uint*(v) conversions beside the unsigned types. So the source may be an integer of any width and either signedness, 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.
  • Across signedness at one width is a bit reinterpretation that moves nothing, so uint8(-1) is 255 and int8(255u8) is -1. Widening extends by the source’s signedness, never the target’s, so uint64(uint8(-1)) zero extends to 255 while uint64(int8(-1)) sign extends to the full width. A float to an unsigned width saturates into [0, 2^w - 1], so uint8(-5.0) is 0 and a NaN casts to 0, lowering through @llvm.fptoui.sat the way the signed direction uses @llvm.fptosi.sat.

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 twelve 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. 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, and a missing value is Maybe.None or an error. The ternary itself is not on that list any more, since 1.13.3 added it as the operator form of the if expression. 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 <> 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, also legal in an expression
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
}