Guides · 12

Interfaces

An interface is a behavioural shape: a set of method signatures. Any value whose type has matching methods can be boxed into an interface value, and calls on that value dispatch dynamically to whatever concrete type is inside. Vyi's twist on this familiar idea comes from its method system: methods live in lexical scope, not inside types (guide 04) — so which methods satisfy an interface is decided by what's in scope at the place you box. This section builds up to why that matters.

Declaring and satisfying an interface

An interface declares method signatures, separated by commas like struct fields. Satisfaction is structural — a type satisfies an interface when methods with matching names and signatures are in scope for it. There is no implements keyword and the type's author never has to opt in:

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: Dog satisfies Speaker
    stdout.writeInt(i64(s.speak()))
    stdout.write("
")
    return Ok(0)   // dynamic dispatch → 42
}

interface Name { … } is shorthand for a type binding — the longhand form is type Speaker = interface { fn speak() i32, }, and both declare the same thing.

If the methods aren't there, 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)
}

Boxing and the fat pointer

The cast site — con s: Speaker = &d — is where an interface value is built. It is a fat pointer: a pair of the data pointer and a vtable, a table with one function entry per interface method. A call like s.speak() reads the entry and calls through it — an indirect call, resolved at runtime by whatever vtable the value carries.

The data side is always a pointer. An interface value doesn't copy the concrete value in — it points at it — so you box a *T, never a bare T:

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

struct Dog { volume: i32 }

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

fn main() !u8 {
    con d = Dog{ volume: 3 }
    con s: Speaker = d         // error: box from a pointer — write &d
    return Ok(0)
}

(Why? A fat pointer holding an inline copy would need a different size per concrete type; holding a pointer keeps every Speaker the same two words, whatever is inside.)

Interface values as parameters, fields, and returns

An interface type goes anywhere a type goes. A parameter of interface type accepts any satisfying concrete, and each boxed argument carries its own vtable — one function body, genuinely polymorphic calls:

vyi
import { stdout } from "@io"

interface Valued {
    fn value() i32,
}

struct Doubler { n: i32 }
struct Tripler { n: i32 }

fn (d: Doubler) value() i32 { return d.n * 2 }
fn (t: Tripler) value() i32 { return t.n * 3 }

fn apply(v: Valued) i32 {
    return v.value()          // dispatches per argument
}

fn main() !u8 {
    con d = Doubler{ n: 21 }
    con t = Tripler{ n: 4 }
    stdout.writeInt(i64(apply(&d) + apply(&t)))
    stdout.write("
")
    return Ok(0)   // 42 + 12
}

Struct fields and mut bindings hold interface values too, and reassignment installs the new concrete's vtable:

vyi
import { stdout } from "@io"

interface Shape {
    fn area() i32,
}

struct Sq { pub s: i32 }
fn (q: Sq) area() i32 { return q.s * q.s }

struct Ci { pub r: i32 }
fn (c: Ci) area() i32 { return c.r * c.r * 3 }

struct Scene { pub shape: Shape }

fn main() !u8 {
    con sq = Sq{ s: 3 }
    con ci = Ci{ r: 2 }
    con scene = Scene{ shape: &sq }
    mut current: Shape = &sq
    con a = current.area()     // 9, through Sq's vtable
    current = &ci
    con b = current.area()     // 12, through Ci's vtable
    stdout.writeInt(i64(scene.shape.area() + a + b))
    stdout.write("
")
    return Ok(0)
}

A function can return an interface — the data side is a pointer, so what it points at must outlive the call. Returning a box over the callee's own local would dangle; box over pointers the caller handed you (or allocator-owned memory):

vyi
import { stdout } from "@io"

interface Shape {
    fn area() i32,
}

struct Sq { pub s: i32 }
fn (q: Sq) area() i32 { return q.s * q.s }

struct Ci { pub r: i32 }
fn (c: Ci) area() i32 { return c.r * c.r * 3 }

fn pick(which: i32, sq: *mut Sq, ci: *mut Ci) Shape {
    if which == 0 { return sq }
    return ci
}

fn main() !u8 {
    mut sq = Sq{ s: 3 }
    mut ci = Ci{ r: 2 }
    con first = pick(0, &sq, &ci)
    con second = pick(1, &sq, &ci)
    stdout.writeInt(i64(first.area() + second.area()))
    stdout.write("
")
    return Ok(0)   // 9 + 12
}

Scope-captured witness tables — the key idea

Here is the part that is distinctly Vyi. In most languages, "does type T have method m" is a global fact about T. In Vyi, methods are free-standing declarations resolved through lexical scope — so the question only has an answer somewhere in particular. That somewhere is the cast site: when you box a value, the compiler resolves each interface method exactly the way a direct call d.speak() 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.

This gives you open extension. A third-party type — one whose module declares no methods at all — can satisfy your interface, because you can put the methods in scope:

vyi
// file: geometry.vyi
// A library type you don't control — no methods here.
export struct Square {
    pub side: i32,
}

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

interface Area {
    fn area() i32,
}

// Your method on their type. It's in scope here, so here Square satisfies Area.
fn (s: Square) area() i32 {
    return s.side * s.side
}

fn main() !u8 {
    con sq = Square{ side: 6 }
    con a: Area = &sq
    stdout.writeInt(i64(a.area() + 6))
    stdout.write("
")
    return Ok(0)   // 36 + 6
}

And it gives you coherence without a global registry. Two modules can each define their own Buffer with their own read — same spelling, different types, different bodies — and each boxing captures its own scope's method. Nothing leaks across:

vyi
// file: reader.vyi
export interface Reader {
    fn read() i32,
}

// file: telemetry.vyi
import { Reader } from "./reader"

struct Buffer {
    payload: i32,
}

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

export fn probe() i32 {
    con buf = Buffer{ payload: 5 }
    con r: Reader = &buf       // captures THIS file's read
    return r.read()            // 105
}

// file: main.vyi
import { Reader } from "./reader"
import { probe } from "./telemetry"
import { stdout } from "@io"

struct Buffer {                // a different Buffer, same name — no conflict
    payload: i32,
}

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

fn main() !u8 {
    con buf = Buffer{ payload: 7 }
    con r: Reader = &buf       // captures this file's read
    stdout.writeInt(i64(r.read()))
    stdout.write("
")
    stdout.writeInt(i64(probe()))
    stdout.write("
")
    return Ok(0)   // 207 and 105
}

The flip side: a method being in scope somewhere else in the program does you no good. If read isn't visible where you box, the box fails — the compiler will never quietly wire your value to some other scope's method:

vyi-error
// file: telemetry.vyi
// This module's Buffer has a read — but only in THIS scope.
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)
}

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 — no boxing required at the call site — and its body runs against the interface, calling back through the declared methods:

vyi
import { stdout } from "@io"

interface Adder {
    fn add(n: i32) i32,
}

struct Counter { base: i32 }

fn (c: Counter) add(n: i32) i32 {
    return c.base + n
}

// One implementation for every type that satisfies Adder.
fn (a: Adder) bumped() i32 {
    return a.add(1)
}

fn main() !u8 {
    con c = Counter{ base: 40 }
    stdout.writeInt(i64(c.bumped()))
    stdout.write("
")
    return Ok(0)   // 41
}

This is how you ship an interface with rich derived behaviour while keeping the required surface small: satisfy one or two methods, inherit the rest.

Dynamic dispatch over generics

Interfaces and generics (guide 11) compose in both directions. A generic interface is — like every generic type — a function returning a type:

vyi
import { stdout } from "@io"

fn Reader(known E: type) type {
    return interface {
        fn read() E,
    }
}

struct Cursor { v: i32 }

fn (c: Cursor) read() i32 {
    return c.v
}

fn readThrough(r: Reader(i32)) i32 {
    return r.read()
}

fn main() !u8 {
    con c = Cursor{ v: 42 }
    con r: Reader(i32) = &c    // Cursor's read() i32 matches Reader(i32)
    stdout.writeInt(i64(readThrough(r)))
    stdout.write("
")
    return Ok(0)
}

Reader(i32) is a concrete interface (instantiation is memoized, so every mention is the same type), and satisfaction is checked against the substituted signatures.

In the other direction, monomorphized generic concretes box like anything else — even when the method's receiver is itself generic. The cast site binds the witness to the right monomorph:

vyi
import { stdout } from "@io"

interface Valued {
    fn get() i32,
}

fn Box(known T: type) type {
    return struct { pub value: T, pub tag: i32 }
}

fn (b: Box(T)) get() i32 {     // generic receiver: one declaration, per-T bodies
    return b.value + b.tag
}

fn main() !u8 {
    con b: Box(i32) = _{ value: 40, tag: 2 }
    con v: Valued = &b         // witness = get() for the T = i32 monomorph
    stdout.writeInt(i64(v.get()))
    stdout.write("
")
    return Ok(0)     // 42
}

Interfaces in the standard library

The canonical interface in the standard library is Allocator, from @mem. Its required surface is two methods — alloc(size, align) and free(buf) — and the typed conveniences new and delete are methods on the interface itself (the pattern from above), with known T parameters so each element type gets its own monomorph:

vyi
import { Allocator } from "@mem"
import { systemAllocator } from "@mem"
import { stdout } from "@io"

fn firstPlusLen(a: Allocator, n: i32) !i32 {
    con buf = try a.new(i32, n)    // typed allocation through the interface
    defer a.delete(buf)            // typed free; T inferred from buf
    buf[0] = 40
    return Ok(buf[0] + buf.len)
}

fn main() !u8 {
    con v = try firstPlusLen(systemAllocator, 2)
    stdout.writeInt(i64(v))
    stdout.write("
")
    return Ok(0)
}

firstPlusLen works with any allocator — the system allocator here, an arena or a fixed buffer in a test — because it only ever sees the interface. This is the shape to copy in your own APIs: accept the interface, let callers choose the concrete.

See also: reference/methods-dispatch.