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 .:
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:
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.
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:
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.
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:
// 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:
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:
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:
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:
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:
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:
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):
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.
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:
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, …:
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.
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":
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:
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:
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:
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:
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.