Guides · 03

Structs, enums, and tuples

Vyi has three aggregate families: structs (named fields), enums (one of several variants, each optionally carrying a payload), and tuples (positional elements). All three are plain values — assigning, passing, or returning one copies its bytes. There are no hidden references and no hidden allocation. A fourth kind, the structural union A | B, is a set of permissible types rather than a declared shape; it closes the chapter.

Structs

A struct declares named, typed fields. You build one with a literal that names every field, and read fields with .:

vyi
import { stdout } from "@io"

struct User {
    name: [*]u8,
    age: i32,
}

fn main() !u8 {
    con u = User{ name: "ada", age: 36 }
    stdout.writeInt(i64(u.age))   // prints 36
    stdout.write("\n")
    return Ok(0)
}

Structs are nominal: each struct declaration is its own type, methods attach to that name (see guide 04), and two structs with the same fields are still different types — though their values convert freely, as the next section shows.

Structs are values. Assignment copies; the copy and the original then live independent lives:

vyi
import { stdout } from "@io"

struct Point { x: i32, y: i32 }

fn main() !u8 {
    con a = Point{ x: 1, y: 2 }
    mut b = a          // a full copy of a's bytes
    b.x = 100          // a.x is still 1
    stdout.writeInt(i64(a.x))   // prints 1
    stdout.write(" ")
    stdout.writeInt(i64(b.y))   // prints 2
    stdout.write("\n")
    return Ok(0)
}

Every field needs a value — or a default

A struct literal must account for every field. There is no implicit zeroing: a field you don't mention is an error, not a silent 0.

vyi-error
struct User { name: [*]u8, age: i32 }

fn main() !u8 {
    con u = User{ name: "ada" }   // error: missing field "age" in User literal
    return Ok(0)
}

If a field has a sensible resting value, say so once, in the declaration, with a default: field: T = expr. Literals may then omit it:

vyi
import { stdout } from "@io"

struct Server {
    host: [*]u8,
    port: i32 = 80,
    verbose: bool = false,
}

fn main() !u8 {
    con s = Server{ host: "localhost" }             // port 80, verbose false
    con t = Server{ host: "0.0.0.0", port: 90 }     // default overridden
    stdout.writeInt(i64(s.port))   // prints 80
    stdout.write(" ")
    stdout.writeInt(i64(t.port))   // prints 90
    stdout.write("\n")
    return Ok(0)
}

A default is an ordinary expression of the field's type, typed where the struct is declared. It can be a constant, an aggregate literal (inner: Inner = Inner{ x: 0, y: 0 }), or a call — a runtime default is evaluated once per construction that omits the field, in declared field order alongside the values you provide. In a generic struct the default resolves per instantiation: T-typed defaults take each instance's concrete type.

vyi
import { stdout } from "@io"

fn Wrap(known T: type) type {
    return struct { val: T, d: T = T(3) }
}

fn main() !u8 {
    con a = Wrap(i32){ val: 1 }      // d is an i32 3
    con b = Wrap(i64){ val: i64(2) } // d is an i64 3
    stdout.writeInt(i64(a.d))   // prints 3
    stdout.write(" ")
    stdout.writeInt(b.d)        // prints 3
    stdout.write("\n")
    return Ok(0)
}

The rule is value-or-default, never implicit zero: whoever reads a struct knows every field was set deliberately — either at the literal or in the declaration.

pub fields

Fields are private to their defining file by default. Mark a field pub to let other files read and write it:

vyi
// file: user.vyi
export struct User {
    pub name: [*]u8,
    loginCount: i32 = 0,
}

export fn newUser(name: [*]u8) User {
    return User{ name: name }
}

// file: main.vyi
import { stdout } from "@io"
import { User, newUser } from "./user"

fn main() !u8 {
    con u = newUser("ada")
    stdout.writeInt(i64(u.name.len))   // name is pub — fine; prints 3
    stdout.write("\n")
    return Ok(0)
}

Touching loginCount from main.vyi — in a field access or in a struct literal — is an error: field "loginCount" is private to its defining file. That's also why private fields pair naturally with defaults: outside files can still build the struct as long as every private field has one.

The inferred _{ … } literal

When the context already names the struct type, you don't have to repeat it. _ is Vyi's general inference placeholder, and _{ … } is a struct literal whose type is filled in from the expected type — an annotation, a parameter, a return type, a field:

vyi
import { stdout } from "@io"

struct Point { x: i32, y: i32 }

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

fn main() !u8 {
    con p: Point = _{ x: 1, y: 2 }
    con a = sum(_{ x: 3, y: 4 })     // parameter type drives the shape
    stdout.writeInt(i64(a))     // prints 7
    stdout.write(" ")
    stdout.writeInt(i64(p.x))   // prints 1
    stdout.write("\n")
    return Ok(0)
}

Shape-compatible implicit copies

Vyi has exactly one large implicit conversion: a wider struct copies into a narrower shape-compatible one, dropping the extra fields. It happens at every value-flow site — call arguments, returns, initialisers, struct-literal fields:

vyi
import { stdout } from "@io"

struct Point { x: i32, y: i32 }
struct Vec3  { x: i32, y: i32, z: i32 }
struct Holder { p: Point }

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

fn shrink(v: Vec3) Point {
    return v                       // return site: z dropped
}

fn main() !u8 {
    con v = Vec3{ x: 1, y: 2, z: 99 }
    con a = sumXY(v)               // call site: Vec3 → Point, z dropped
    con p: Point = v               // initialiser site
    con h = Holder{ p: v }         // struct-literal site
    con q = shrink(v)
    stdout.writeInt(i64(a))       // prints 3
    stdout.write(" ")
    stdout.writeInt(i64(p.x))     // prints 1
    stdout.write(" ")
    stdout.writeInt(i64(h.p.y))   // prints 2
    stdout.write(" ")
    stdout.writeInt(i64(q.x))     // prints 1
    stdout.write("\n")
    return Ok(0)
}

Fields match by name, not by position, so declaration order doesn't matter. And it's a copy, not aliasing: v is untouched afterwards, and writing to p never affects v. When the two types have exactly the same fields the copy works in both directions — nominal identity keeps Celsius and Fahrenheit distinct for methods, while their values still flow:

vyi
import { stdout } from "@io"

struct Celsius    { degrees: i32 }
struct Fahrenheit { degrees: i32 }

fn main() !u8 {
    con c = Celsius{ degrees: 100 }
    con f: Fahrenheit = c          // same shape: copies field-by-name
    stdout.writeInt(i64(f.degrees))   // prints 100
    stdout.write("\n")
    return Ok(0)
}

You can also spell the copy explicitly by using the target type like a function — Point(v) — which reads well when the conversion is the point of the line.

The conversion only goes wider → narrower (or equal). A narrower struct can't fill a wider one, because Vyi would have to invent the missing fields:

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

fn main() !u8 {
    con p = Point{ x: 1, y: 2 }
    con v: Vec3 = p                // error: cannot assign Point to Vec3
    return Ok(0)
}

If you're coming from Rust, Zig, or C++, this is the delta to absorb: those languages make you write the projection by hand (a From impl, a field-by-field copy). Vyi treats "this function only needs these fields" as an ordinary fact about the call, so passing a richer value just works — the callee sees precisely the shape it asked for, and nothing aliases.

Enums

An enum is a closed set of uniquely-named variants. A variant may be bare, or carry a payload — one value, several, or a whole aggregate:

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

enum Shape {
    Dot,                // no payload
    Circle(i32),        // one scalar: radius
    Rect(i32, i32),     // two scalars: w, h
    Poly(Point),        // an aggregate payload
}

Construct a variant by calling it. The fully qualified form Enum.Variant always works; when the expected type at the use site is already the enum — an annotation, a parameter, a return type, a switch subject — you can name the variant bare:

vyi
import { stdout } from "@io"

enum Shape { Dot, Circle(i32), Rect(i32, i32) }

fn area(s: Shape) i32 {
    switch s {
        Dot:        { return 0 },
        Circle(r):  { return 3 * r * r },
        Rect(w, h): { return w * h },
    }
}

fn main() !u8 {
    con a = Shape.Circle(5)      // qualified
    con b: Shape = Rect(2, 3)    // bare: the annotation names the enum
    stdout.writeInt(i64(area(Dot)))   // bare in argument position too; prints 0
    stdout.write(" ")
    stdout.writeInt(i64(area(a)))     // prints 75
    stdout.write(" ")
    stdout.writeInt(i64(area(b)))     // prints 6
    stdout.write("\n")
    return Ok(0)
}

Bare-variant resolution is a general rule, not a special case: None, Some, Ok, and Err from the optionals and errors guides are ordinary variants of ordinary enums, resolved exactly like Dot here.

== and != are built in for an enum when every payload is itself equatable — payload-less variants, scalar payloads, and so on compare by tag then payload. A struct payload switches that off, because struct equality is opt-in (a struct compares with == only once you define an @eq method — see guide 13):

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

enum Node { Leaf, At(Point) }

fn main() !u8 {
    con a = Node.At(Point{ x: 1, y: 2 })
    con b = Node.At(Point{ x: 1, y: 2 })
    if a == b { return Ok(0) }   // error: `==` is not implemented for Node
    return Ok(0)
}

Defining fn (a: Node) @eq(b: Node) bool makes == legal again, with your semantics.

Consuming enums with switch

switch over an enum is exhaustive — every variant must be handled — and each arm binds the payload directly into fresh names, as Circle(r) and Rect(w, h) did above. Forgetting a variant is a compile error, which is the point: add a variant next month and the compiler lists every switch that now needs a decision.

vyi-error
enum Shape { Dot, Circle(i32), Rect(i32, i32) }

fn area(s: Shape) i32 {
    switch s {                   // error: non-exhaustive match; missing Rect
        Dot:       { return 0 },
        Circle(r): { return 3 * r * r },
    }
    return 0
}

fn main() !u8 {
    return Ok(0)
}

Comma-separated patterns share one body, any: is the deliberate catch-all (it satisfies exhaustiveness, so use it sparingly), and switch is an expression — each arm's block yields the arm's value:

vyi
import { stdout } from "@io"

enum Color { Red, Green, Blue, Violet }

fn main() !u8 {
    con c = Color.Green
    con warmth = switch c {
        Red:         { 2 },
        Green, Blue: { 1 },      // alternatives share a body (no binding)
        any:         { 0 },      // catch-all
    }
    stdout.writeInt(i64(warmth))   // prints 1
    stdout.write("\n")
    return Ok(0)
}

Guards, nested patterns, and the if Some(v) = x single-pattern form are covered in guides 05 and 06.

Tuples

A tuple groups values positionally — no declaration, no field names. Access elements with .0, .1, …:

vyi
import { stdout } from "@io"

fn minMax(a: i32, b: i32) (i32, i32) {
    if a < b { return (a, b) }
    return (b, a)
}

fn main() !u8 {
    con r = minMax(9, 4)
    stdout.writeInt(i64(r.0))   // prints 4
    stdout.write(" ")
    stdout.writeInt(i64(r.1))   // prints 9
    stdout.write("\n")
    return Ok(0)
}

Tuples are ordinary values: annotate them (con m: (i32, i64, i32) = …), nest them, store them in struct fields, and pass or return them by copy like any other aggregate.

vyi
import { stdout } from "@io"

struct Point { x: i32, y: i32 }

fn main() !u8 {
    con pair = (Point{ x: 1, y: 2 }, Point{ x: 3, y: 4 })
    con nested = ((1, 2), 30)
    stdout.writeInt(i64(pair.1.y))    // prints 4
    stdout.write(" ")
    stdout.writeInt(i64(nested.0.0))  // prints 1
    stdout.write(" ")
    stdout.writeInt(i64(nested.1))    // prints 30
    stdout.write("\n")
    return Ok(0)
}

The empty tuple () is Vyi's unit: a real zero-size type and value. A function with no declared return type returns (), and () also serves as the payload-free error set — ()!T is "a T, or a failure that carries nothing":

vyi
import { stdout } from "@io"

fn checked(x: i32) ()!i32 {
    if x < 0 { return Err(()) }
    return Ok(x * 2)
}

fn main() !u8 {
    con u = ()                        // the unit value
    con a = checked(5) catch { 0 }
    stdout.writeInt(i64(a))   // prints 10
    stdout.write("\n")
    return Ok(0)
}

Reach for a tuple when the grouping is incidental — "these two values travel together for a moment." The moment the elements deserve names, declare a struct.

Structural unions

A | B is a structural union: the value is one of the listed types, with no nominal wrapper and no variant names — the member types themselves are the cases. Construction is implicit; producing any member type produces the union:

vyi
fn parse(s: [*]u8) i32 | bool {
    if s.len == 0 { return false }   // a bool is an (i32 | bool)
    return i32(s[0])                 // so is an i32
}

You consume a union by dispatching on the member type with switch typeof. Inside each arm the value narrows to that member, so you can use it at its real type:

vyi
import { stdout } from "@io"

fn parse(s: [*]u8) i32 | bool {
    if s.len == 0 { return false }
    return i32(s[0])
}

fn main() !u8 {
    con v = parse("a")
    switch typeof v {
        i32:  {
            stdout.writeInt(i64(v - 96))   // v is an i32 here; prints 1
            stdout.write("\n")
        },
        bool: {
            stdout.write("false\n")        // and a bool here
        },
    }
    return Ok(0)
}

Switching on the union value directly is an error — patterns match values, and a union's cases are types:

vyi-error
fn main() !u8 {
    con u: i32 | bool = 1
    switch u {                 // error: dispatch with `switch typeof u`
        i32:  { return Ok(0) },
        bool: { return Ok(0) },
    }
    return Ok(0)
}

Unions widen implicitly: a value of a narrower union flows into any union that contains all its members — at bindings, arguments, and returns — and the live member is preserved:

vyi
import { stdout } from "@io"

fn widen(v: i32 | u8) bool | i32 | u8 {
    return v                              // return-site widening
}

fn main() !u8 {
    con narrow: i32 | u8 = u8(40)
    con wide: bool | i32 | u8 = narrow    // binding-site widening
    switch typeof wide {
        u8:   {
            stdout.writeInt(i64(wide))   // still the live member; prints 40
            stdout.write("\n")
        },
        i32:  { stdout.write("i32\n") },
        bool: { stdout.write("bool\n") },
    }
    return Ok(0)
}

Unions are also what inferred error sets are made of: a function returning bare !T gets, as its error type, the minimal union of the error types its body actually produces. Handling errors is therefore the same skill as narrowing any union (guide 07).

See also: reference/types.