Docs · Language

Methods and dispatch

A method is a free-standing fn with a receiver written before the name — methods are not members of a type's definition, and which methods a type has is a property of what's in scope, not of the type's identity. This page is the precise reference for receiver forms, method-set visibility, overload selection, and interface dispatch. Narrative introductions: guides 04 — Functions and closures, 12 — Interfaces, and 14 — Modules and packages.

Receiver forms

ReceiverExampleThe method gets
valuefn (p: Point) name()a copy; changes never reach the caller
pointer, writablefn (p: *mut Point) name()the original's address; may mutate
pointer, read-onlyfn (p: *con Point) name()the original's address, for reading only
typefn (Point) name()no value — called on the type itself

There is no bare *T receiver — a pointer receiver states its write permission, *mut or *con, like every pointer type (reference/memory-model). Methods may be declared on any type; the pointer rules below are an overlay on one level of *mut / *con around that type.

vyi
import { stdout } from "@io"

struct Point {
    x: i32,
    y: i32,
}

fn (Point) origin() Point {
    return Point{ x: 0, y: 0 }
}

fn (p: Point) lengthSquared() i32 {
    return p.x * p.x + p.y * p.y
}

fn (p: *con Point) xCoord() i32 {
    return p.x
}

fn (p: *mut Point) translate(dx: i32, dy: i32) {
    p.x = p.x + dx
    p.y = p.y + dy
}

fn main() !u8 {
    mut p = Point{ x: 3, y: 4 }
    con before = p.lengthSquared()   // 25 — value method: copy
    p.translate(1, 1)                // *mut method: auto-ref of mut place
    con after = p.lengthSquared()    // 41
    con x = p.xCoord()               // *con method: auto-ref (read-only)
    mut pm: *mut Point = &p
    con throughPtr = pm.lengthSquared()  // value method on *mut: load a copy
    con o = Point.origin()           // type receiver
    stdout.writeInt(i64(before + after + x + throughPtr + o.x - 24 - 4 - 41))  // prints 42
    stdout.write("\n")
    return Ok(0)
}

Receiver adaptation

Instance methods adapt across one level of *mut / *con around the receiver's base type. Type-receiver methods (fn (T) …) do not participate — they stay type-only.

DeclaredCall subjectAdaptationMutates caller?
Tvalue of Tpass a copyno
T*mut T / *con Tload a copy of the pointeeno
*mut Twritable place of Tauto-ref as *mutyes
*mut Tvalue of type *mut Tpass the pointeryes
*mut Tcon T, param, or *con Tnot a candidate
*con Tvalue of T (mut or con)auto-ref as *conno
*con T*mut T / *con Tpass / widen to *conno

So *con methods work on every kind of instance source of T. Value methods always copy. *mut methods need a place you may write through.

Ranking. When several same-name methods apply: (1) exact receiver shape, (2) else pointer adaptation over value copy, (3) else ordinary argument-signature / proximity rules.

vyi
import { stdout } from "@io"

struct Cell { n: i32 }

fn (c: Cell) tag() i32 { return 1 }
fn (c: *mut Cell) tag() i32 { return 2 }
fn (c: *con Cell) tag() i32 { return 3 }

fn main() !u8 {
    mut c = Cell{ n: 0 }
    mut pm: *mut Cell = &c
    con pc: *con Cell = &c
    // exact shape each time: 1 + 2 + 3
    stdout.writeInt(i64(c.tag() + pm.tag() + pc.tag()))
    stdout.write("\n")
    return Ok(0)
}

Diagnostics. A *mut method that is not a candidate is reported like any missing method, with a note that it is defined for *mut T but not callable on this subject:

vyi-error
struct Point { x: i32 }

fn (p: *mut Point) bump() {
    p.x = p.x + 1
}

fn main() !u8 {
    con p = Point{ x: 1 }
    p.bump()   // error: type Point has no method "bump"
               // note: method is defined for *mut Point, but not callable on an immutable value
    return Ok(0)
}

Inside a pointer-receiver method, field access dereferences for you: p.x, not (*p).x.

Static vs instance calls

A type receiver (fn (Point) …) declares a static method, called on the type: Point.origin(). The two call shapes do not cross:

  • a type-receiver method is not callable through an instance —

p.origin() is an error;

  • an instance method is not callable through the type —

Point.lengthSquared(p) is an error; the receiver is passed only by receiver position.

vyi-error
struct P { x: i32 }
fn (P) zero() P { return P{ x: 0 } }

fn main() !u8 {
    con a = P{ x: 3 }
    con b = a.zero()      // error: static method — call P.zero()
    return Ok(0)
}

Method sets are scope-determined

A type's definition lists fields and nothing else. Anyone can declare a method on any type — including primitives and types from other packages:

vyi
import { stdout } from "@io"

fn (n: i32) doubled() i32 {
    return n * 2
}

fn main() !u8 {
    con a: i32 = 7
    stdout.writeInt(i64(a.doubled()))  // prints 14
    stdout.write("\n")
    return Ok(0)
}

Methods travel with types. An exported method declared in the same file as its receiver type rides along when the type is imported — you don't import methods by name, and they follow an import alias too:

vyi
// file: geometry.vyi
export struct Point { pub x: i32, pub y: i32 }
export fn (a: Point) manhattan(b: Point) i32 {
    return (a.x - b.x) + (a.y - b.y)
}

// file: main.vyi
import { Point: P } from "./geometry"
import { stdout } from "@io"

fn main() !u8 {
    con a = P{ x: 5, y: 5 }
    con b = P{ x: 1, y: 2 }
    stdout.writeInt(i64(a.manhattan(b)))  // prints 7 — methods follow the alias
    stdout.write("\n")
    return Ok(0)
}

A method declared in a different file than its receiver type is an extension: it belongs to that file's exports and reaches you through an import — commonly a side-effect import of the whole file, since methods are called through the receiver rather than by bare name:

vyi
// file: shape.vyi
export struct Circle { pub r: i32 }

// file: area.vyi
import { Circle } from "./shape"
export fn (c: Circle) area() i32 { return 3 * c.r * c.r }

// file: main.vyi
import { Circle } from "./shape"
import "./area"                    // load the extension methods
import { stdout } from "@io"

fn main() !u8 {
    con c = Circle{ r: 2 }
    stdout.writeInt(i64(c.area()))  // prints 12
    stdout.write("\n")
    return Ok(0)
}

A file the program never imports contributes nothing — drop the import "./area" line and the call fails:

vyi-error
// file: shape.vyi
export struct Circle { pub r: i32 }

// file: main.vyi
import { Circle } from "./shape"

fn main() !u8 {
    con c = Circle{ r: 2 }
    return Ok(0))   // error: type Circle has no method "area"
}

Method sets are deliberately not part of type identity: a Circle is the same nominal type everywhere; only the operations available on it vary with what's in scope. This is what interface witness capture builds on (below).

Resolution and duplicates

Two different types can carry same-named methods with no conflict — the receiver's type picks the method:

vyi
import { stdout } from "@io"

struct A { v: i32 }
struct B { v: i32 }
fn (a: A) tag() i32 { return 1 }
fn (b: B) tag() i32 { return 2 }

fn main() !u8 {
    con a = A{ v: 0 }
    con b = B{ v: 0 }
    stdout.writeInt(i64(a.tag() * 4 + b.tag()))  // prints 6
    stdout.write("\n")
    return Ok(0)
}

Two declarations with the same receiver type and signature resolve by proximity — the definition closest to the use site wins. The precedence order at a call site: the local scope chain (innermost block outward to the file's own declarations), then the receiver value's import flavor (the alias its annotation named), then methods that traveled in with other imports. Each file's calls resolve against its own view; declaring your own version of a library's method changes your call sites, never the library's.

Overload selection

Functions and methods may share a name when their signatures differ; the call site selects. What selects today:

Differs byFree functionMethod
arityselectsselects
receiver typeselects
parameter types at the same arityselectsdoes not select (planned)
vyi
import { stdout } from "@io"

struct A { v: i32 }
fn (a: A) get() i32 { return a.v }
fn (a: A) get(extra: i32) i32 { return a.v + extra }   // arity selects

fn free(x: bool) i32 { return 1 }
fn free(x: i32) i32 { return 2 }                       // param type selects

fn main() !u8 {
    con a = A{ v: 1 }
    stdout.writeInt(i64(a.get() + a.get(2) + free(5) * 4 + free(true)))  // prints 1+3+8+1 = 13
    stdout.write("\n")
    return Ok(0)
}

On a single receiver type, two same-arity methods are not distinguished by their parameter types — the call type-checks against one of them and mismatches error:

vyi-error
struct A { v: i32 }
fn (a: A) get(x: bool) i32 { return 1 }
fn (a: A) get(x: i32) i32 { return 2 }

fn main() !u8 {
    con a = A{ v: 1 }
    return Ok(0))   // error: same-arity methods don't select by type
}

Arity counts suppliable arguments: a call may omit trailing parameters that declare defaults (guide 04).

Interface satisfaction and witness capture

An interface is a set of method signatures. Satisfaction is structural and checked at the cast site: a type satisfies an interface where methods with matching names and signatures are in scope for it — no implements, and the type's author never opts in. Boxing takes a pointer (&d), because an interface value is a fat pointer: data pointer + vtable.

vyi
import { stdout } from "@io"

interface Speaker {
    fn speak() i32,
}

struct Dog { volume: i32 }

fn (d: Dog) speak() i32 {
    return d.volume
}

fn main() !u8 {
    con d = Dog{ volume: 42 }
    con s: Speaker = &d        // box: satisfaction checked HERE
    stdout.writeInt(i64(s.speak()))  // dynamic dispatch → prints 42
    stdout.write("\n")
    return Ok(0)
}

If the methods aren't in scope at the cast site, the box is rejected where you write it:

vyi-error
interface Speaker {
    fn speak() i32,
}

struct Rock { weight: i32 }

fn main() !u8 {
    con r = Rock{ weight: 3 }
    con s: Speaker = &r        // error: no speak() in scope for Rock
    return Ok(0))
}

The witness is captured at the cast site. Boxing resolves each interface method exactly as a direct call written at that spot would resolve, and freezes the winners into the vtable — the vtable is a witness of what was in scope where you boxed. Consequences (guide 12 demonstrates each):

  • Open extension — your method on a third-party type satisfies your

interface, because the method is in scope at your cast site.

  • Coherence — two files can each box their own Buffer with their own

read; each cast captures its own scope's method, and nothing leaks across.

  • No action at a distance — a method in scope somewhere else in the

program does not satisfy a box written here:

vyi-error
// file: telemetry.vyi
export struct Buffer {
    pub payload: i32,
}

fn (b: Buffer) read() i32 {
    return b.payload
}

// file: main.vyi
import { Buffer } from "./telemetry"

interface Reader {
    fn read() i32,
}

fn main() !u8 {
    con b = Buffer{ payload: 9 }
    con r: Reader = &b         // error: read() is not in scope HERE
    return Ok(0))
}

Two more dispatch facts:

  • Methods on the interface itself. An interface is a type, so it can be a

receiver; a method declared on the interface is callable on any satisfying value and dispatches back through the declared methods (this is how @mem's Allocator ships new/delete on top of alloc/free).

  • Generic receivers. A method with a generic receiver

(fn (b: Box(T)) get() …) boxes per monomorph — the cast site binds the witness to the T in hand (guide 12).

Union receivers

A method call on a tagged-union value (e: SaveError | ParseError) has no method of its own to find — dispatch is member-wise: the method must be defined on every member, and the call compiles to a runtime switch on the union's tag, each arm invoking that member's method with the live member as its receiver. A member without the method rejects the call at the call site (`method "m" is not defined for union member T`).

The members' methods need not agree on a return type: the call's static type is the implicit-widening union of the member return types (identical returns collapse to that single type), and each arm's result widens into it. Leading known parameters work as on any receiver — each member resolves its own monomorph from the call's type arguments, spelled out or inferred:

vyi
import { stdout } from "@io"

enum SaveError { DiskFull }
struct ParseError { code: i32 = 0 }

fn (e: SaveError) describe() i32 { return 100 }
fn (e: ParseError) describe() bool { return e.code == 7 }

fn (e: SaveError) pick(known T: type, v: T) T { return v }
fn (e: ParseError) pick(known T: type, v: T) T { return v }

fn main() !u8 {
    con u: SaveError | ParseError = SaveError.DiskFull
    con a = u.describe()          // i32 | bool — the SaveError arm: 100
    con p = u.pick(i32, 20) + u.pick(3)   // per-member known monomorphs
    switch typeof a {
        i32:  {
            stdout.writeInt(i64(a - 100 + p + 19))   // prints 42
            stdout.write("\n")
            return Ok(0)
        },
        bool: { return Ok(0) },
    }
}

Receivers are values here as a semantic fact: a union is a value whose live member lives inline in its {tag, payload} block, so there is no *mut member place for a pointer receiver to point at — pointer-receiver methods do not participate in union dispatch. To mutate, switch on the member (switch typeof) and rebuild the union value.

@-methods and member access

The @ sigil is part of an operator method's name. Plain member access never resolves to one — a.add(b) does not find @add:

vyi-error
struct W { v: i32 }
fn (a: W) @add(b: W) W { return W{ v: 9 } }

fn main() !u8 {
    con a = W{ v: 1 }
    con b = a.add(a)   // error: W has no method "add" — the name is @add
    return Ok(0)
}

Operator methods are reached through their operator (a + b) or the explicit sigiled spelling (a.@add(b)); both resolve by the rules on this page. The full operator↔method table is in reference/operators.

See also: reference/operators, guide 14 — Modules and packages for export/import mechanics, guide 12 — Interfaces for the interface model in full.