Docs · Language

Operators

Every binary operator in Vyi is a method call: a + b means a.@add(b), xs[i] means xs.@index(i). The compiler implements these methods for the primitive types; user types implement them as ordinary scope-resolved methods. This page is the exhaustive table form — precedence, the operator↔method mapping, compound assignment, and the built-in semantics. The narrative introduction is guide 13 — Operators and overloading.

Precedence

From loosest to tightest binding. All binary operators of one level associate left:

LevelOperatorsClass
1.. ...range (does not chain)
2||logical or (short-circuit)
3&&logical and (short-circuit)
4|bitwise or
5^bitwise xor
6&bitwise and
7== !=equality
8< > <= >=comparison
9<< >>shift
10+ -additive
11* / %multiplicative
12- ! * (deref) & (address-of)unary prefix
13.field [index] (call)postfix chain, left to right

Two syntactic rules that interact with precedence:

  • A newline ends the expression. A binary operator must sit on the same

line as its left operand; there is no line-continuation form.

  • Ranges do not chain1..2..3 is a parse error.

The consequences, verified:

vyi
import { stdout } from "@io"

fn main() !u8 {
    mut ok = 0
    if 2 + 3 * 4 == 14 { ok = ok + 1 }        // * before +, arithmetic before ==
    if (2 + 3) * 4 == 20 { ok = ok + 1 }      // parens override
    if 10 - 2 - 3 == 5 { ok = ok + 1 }        // left associative
    if 1 << 2 + 3 == 32 { ok = ok + 1 }       // + before <<
    if 1 << 3 < 10 { ok = ok + 1 }            // << before <
    if 3 < 4 && 5 < 6 { ok = ok + 1 }         // comparison before &&
    if true || false && false { ok = ok + 1 } // && before ||
    con masked = (1 | 2 ^ 3 & 5) == 3         // & then ^ then |
    if masked { ok = ok + 1 }
    stdout.writeInt(i64(ok))  // prints 8
    stdout.write("\n")
    return Ok(0)
}

Bitwise &/|/^ sit looser than ==/!= (matching C), so mask tests need parentheses — without them the equality is computed first and the operand types no longer line up:

vyi-error
fn main() !u8 {
    con flags = 6
    con x = flags & 4 != 0   // parses as flags & (4 != 0): i32 & bool
    return Ok(0)
}

Write (flags & 4) != 0.

Binary operators → methods

The left operand is the receiver; the right operand is the single argument. Both operands must have the same type — there is no implicit numeric widening, and the desugar target's parameter type is the receiver's type.

OperatorDesugars toNotes
+@add
-@sub
*@mul
/@div
%@rem
==@eqreturns bool
!=@eqderived: result negated
<@ltreturns bool
<=@lereturns bool
>@ltderived: operands swapped — a > b is b.@lt(a)
>=@lederived: operands swapped
&@bandinfix; prefix & is address-of
|@borinfix; | in type position builds a union
^@bxor
<<@shl
>>@shr
&&not a method: short-circuits, both sides bool
||not a method: short-circuits, both sides bool

There is no @neq, @gt, or @ge!=, >, and >= are always derived from @eq, @lt, and @le, so three methods buy the whole comparison set.

vyi
import { stdout } from "@io"

struct Vec2 { x: i32, y: i32 }

fn (a: Vec2) @add(b: Vec2) Vec2 {
    return Vec2{ x: a.x + b.x, y: a.y + b.y }
}
fn (a: Vec2) @eq(b: Vec2) bool {
    return a.x == b.x && a.y == b.y
}

fn main() !u8 {
    con a = Vec2{ x: 1, y: 2 }
    con b = Vec2{ x: 3, y: 4 }
    con c = a + b              // a.@add(b)
    con d = a.@add(b)          // the explicit spelling — same call
    mut ok = 0
    if c == d { ok = ok + 1 }  // a.@eq(b)
    if c != a { ok = ok + 1 }  // !(c.@eq(a)) — derived
    stdout.writeInt(i64(c.x + c.y + ok))  // prints 4 + 6 + 2 = 12
    stdout.write("\n")
    return Ok(0)
}

Using an operator with no method in scope is a compile error, never a silent default:

vyi-error
struct Point { x: i32, y: i32 }

fn main() !u8 {
    con a = Point{ x: 1, y: 2 }
    con b = Point{ x: 3, y: 4 }
    con c = a + b   // error: operator `+` is not implemented for type Point
    return Ok(0)
}

The same-type rule applies to the operands as written, so mixed-type operator methods (say, scaling a vector by an i32) are not reachable through the operator symbol — spell the call explicitly:

vyi
import { stdout } from "@io"

struct V { pub x: i32 }
fn (a: V) @mul(k: i32) V { return V{ x: a.x * k } }

fn main() !u8 {
    con a = V{ x: 3 }
    con b = a.@mul(2)          // explicit call: fine
    stdout.writeInt(i64(b.x))  // `a * 2` would not type-check
    stdout.write("\n")
    return Ok(0)
}
vyi-error
fn main() !u8 {
    con a: i32 = 1
    con b: i64 = 2
    con c = a + b   // error: operands must share one type — cast first
    return Ok(0)
}

Unary operators

OperatorMeaningOperand
-xnumeric negationbuilt-in integers and floats
!xlogical notbool only; not overloadable
&xaddress-ofany addressable value → *mut T / *con T
*pdereferencepointers

@neg is the method name reserved for unary minus, and an explicit a.@neg() call resolves like any method — but operator dispatch of -a to a user-defined @neg is (planned); today - is the built-in numeric negation only.

vyi-error
fn main() !u8 {
    con x = !1      // error: `!` requires a bool operand
    return Ok(0)
}

Prefix &/* bind tighter than every binary operator and looser than the postfix chain: -p.x is -(p.x), *p + 1 is (*p) + 1. Address-of and dereference semantics are covered in reference/memory-model.

Indexing and slicing

Subscripts are methods:

SyntaxDesugars to
xs[i] (read)xs.@index(i)
xs[i] = v (write)xs.@setIndex(i, v)
xs[a..b]xs.@slice(Range{ start: a, end: b })
xs[a...b]xs.@slice(Range{ start: a, end: b + 1 })

A compound assignment through a subscript reads through @index and writes through @setIndex. @setIndex typically takes a *mut receiver so it can mutate:

vyi
import { stdout } from "@io"

struct Counts { inner: [*]i32 }

fn (c: Counts) @index(i: i32) i32 { return c.inner[i] }
fn (c: *mut Counts) @setIndex(i: i32, v: i32) { c.inner[i] = v }
fn (c: Counts) @slice(r: Range) [*]i32 { return c.inner[r.start..r.end] }

fn main() !u8 {
    mut cells = [3]i32[5, 0, 9]
    mut c = Counts{ inner: [*]i32(&cells) }
    c[1] = 9      // c.@setIndex(1, 9)
    c[1] += 1     // read via @index, write via @setIndex
    con head = c[0..2]           // c.@slice(Range{ start: 0, end: 2 })
    stdout.writeInt(i64(c[1] + head.len))  // prints 10 + 2 = 12
    stdout.write("\n")
    return Ok(0)
}

This is exactly how @core's collections work — @index/@setIndex/@slice on Vec(T) and [*]T are written in ordinary Vyi; nothing about subscripting is reserved for built-in types.

Range operators

a..b builds a Range — an ordinary @core struct with start and end fields — whose end is exclusive by convention everywhere a Range is consumed. a...b is the inclusive spelling; the bump to end + 1 is applied where the range expression is used directly:

Use sitea..ba...b
for x in a..biterates a to b - 1iterates a to b
xs[a..b]slice, end exclusiveslice, end inclusive
plain value positionRange{ start: a, end: b }Range{ start: a, end: b }

Note the last row: a stored range is a plain two-field value with half-open meaning, so the inclusive spelling only changes behaviour at a direct use site — bind con r = a...b and you have stored the same value as a..b. Ranges at level 1 bind loosest, so 1..2 + 3 is 1..(2 + 3):

vyi
import { stdout } from "@io"

fn main() !u8 {
    con r = 1..2 + 3
    mut s = 0
    for x in r.start..r.end { s = s + x }   // 1+2+3+4 = 10
    for y in 1...3 { s = s + y }            // + 1+2+3 = 16
    con w = "hello"
    con mid = w[1..3]                       // "el"
    con midInc = w[1...3]                   // "ell"
    stdout.writeInt(i64(s + mid.len + midInc.len))  // prints 16 + 2 + 3 = 21
    stdout.write("\n")
    return Ok(0)
}

Open-ended forms exist in index position only: xs[..b] (from the start), xs[a..] (to the end), xs[..] (the whole view). A range stored in a binding is not itself iterable — for x in a..b iterates the range syntactically (guide 05).

Compound assignment

All ten compound forms exist. Each looks for its in-place override method first, and falls back to the plain operator plus a store when the override is absent:

CompoundOverrideFallbackCompoundOverrideFallback
+=@compAdd@add&=@compBand@band
-=@compSub@sub|=@compBor@bor
*=@compMul@mul^=@compBxor@bxor
/=@compDiv@div<<=@compShl@shl
%=@compMod@rem>>=@compShr@shr

(The one naming asymmetry: % is @rem, but %= is @compMod.)

Rules:

  • Fallback rewrite: place OP= value becomes place = place OP value,

with the place evaluated once. Because the fallback goes through the plain operator, it inherits the same-type rule — a compound with a different right-hand type requires the @comp* override.

  • Override shape: a @comp* method takes a *mut receiver, mutates

through it, and returns (). Its parameter type is free — t += 20 with fn (t: *mut Tracked) @compAdd(n: i32) is fine.

  • Assignment is a statement. Neither = nor any compound form produces a

value; con y = (x += 1) does not parse as an expression.

vyi
import { stdout } from "@io"

struct Tracked { pub v: i32, pub writes: i32 }

fn (a: *mut Tracked) @compAdd(b: i32) {
    a.v = a.v + b
    a.writes = a.writes + 1
}

fn main() !u8 {
    mut t = Tracked{ v: 0, writes: 0 }
    t += 20
    t += 20
    stdout.writeInt(i64(t.v + t.writes))  // prints 40 + 2 = 42
    stdout.write("\n")
    return Ok(0)
}

Tracked has only @compAdd, so t = t + 20 would not compile — the override, not the fallback, is what fires. When both exist, the override wins for OP= and the plain method serves OP.

The effectful-place rule. "Evaluated once" is enforced: a place containing a call would have to run the call twice under the fallback rewrite, so the compiler rejects it and tells you to bind the index first:

vyi-error
fn pick() i32 { return 0 }

fn main() !u8 {
    mut buf = [2]i32[1, 2]
    buf[pick()] += 1     // error: would evaluate the place twice; bind pick() first
    return Ok(0)
}

Built-in vs user resolution

An operator resolves exactly like a method call at that spot (guide 14): the compiler provides the implementations for primitives, @core provides them for the core types in ordinary Vyi source, and anything else is a compile error until a method of yours is in scope.

Operand typeProvided operators
integers, floatsarithmetic, comparisons, equality; integers add bitwise and shifts
bool== !=, && || !
char== !=, ordering
pointers== != (address identity)
singleton unions (1 | 2, "a" | "b")== != by value, built-in
payload-less enums== != (tag compare), built-in
enums whose payloads are all equatable== !=, deep: tag and live payload
[*]u8 (byte views, string literals)== != content equality; [i], slicing — written in @core
String, Vec(T), HashMap(K, V)@eq/@hash, subscripts — written in @core
structsnone built-in — every operator is opt-in via @-methods

Struct equality is deliberately not synthesized (field-by-field equality is not always the equality you want), and an enum's built-in deep == never reaches into a payload type's user @eq — give the enum its own @eq instead (guide 13).

One operator-adjacent method to know: @hash. It backs no operator, but HashMap locates keys by key.@hash() and confirms them with @eq; @core ships @hash for the integer primitives, char, and String. Keep the contract: @eq-equal values must produce the same @hash.

Built-in numeric semantics

OperationRule
integer /truncates toward zero: -7 / 2 == -3
integer %remainder takes the dividend's sign: -7 % 2 == -1
>>arithmetic (sign-extending) on signed types, logical on unsigned
overflowwraps (two's complement)
integer / 0, % 0runtime trap (halts the program)
float %remainder of truncated division: 7.5 % 2.0 == 1.5, sign follows the dividend

@-methods and member access

The @ sigil is part of the method's name. Plain member access never lands on an operator method — a.add(b) does not find @add — and the explicit a.@add(b) spelling is always available and resolves like any other method call. Declaring an @-method is the only way to hook an operator; there is no other overloading mechanism.

See also: reference/expressions for the full expression grammar, reference/methods-dispatch for how the method is found once the operator has desugared.