Docs · Language

Types

This page is the catalogue of Vyi's type grammar: every form a type can take, what each means, and the identity and conversion rules between them. For the narrative versions see guides 02 — Values and bindings, 03 — Structs, enums, tuples, and 09 — Pointers and memory.

A type in Vyi is itself a value — a known value of the meta-type type — so anywhere this page says "type expression," an ordinary expression that the compiler can evaluate at compile time will do. That one fact carries generics, typeof, and values-as-types; the precise rules live in the known values reference.

The type grammar at a glance

FormMeaning
i32, bool, char, …primitives (table below)
()the unit type — the empty tuple
struct { … }struct type (nominal when declared with a name)
enum { … }enum type — closed set of variants, optionally with payloads
interface { … }interface type — a set of required method signatures
(T, U, …)tuple type (structural)
A | Bstructural union — one of the listed types
?Toptional — Optional(T)
E!Terror union — Result(E, T)
!Terror union with inferred error set — Result(_, T)
*mut T, *con Tpointer to one T (writable / read-only)
[N]Tfixed array of N elements; N is part of the type
[*]Tarray pointer — pointer plus runtime length
c_ptr TC-interop pointer: nullable, with pointer arithmetic
fn(SIG) Retfunction type
typethe meta-type: the type whose values are types
anyadmits a value of any type
42, "go", 'a', truesingleton type — inhabited by exactly that value
0..101range type — an integer within the half-open interval
_inference placeholder — solved by the compiler

Primitive types

TypeWidthValues
i88-bit signed−128 … 127
i1616-bit signed−32 768 … 32 767
i3232-bit signed−2 147 483 648 … 2 147 483 647
i6464-bit signed−2⁶³ … 2⁶³−1
u88-bit unsigned0 … 255
u1616-bit unsigned0 … 65 535
u3232-bit unsigned0 … 4 294 967 295
u6464-bit unsigned0 … 2⁶⁴−1
f32IEEE 754 binary32single-precision float
f64IEEE 754 binary64double-precision float
booltrue, false
char32-bitone Unicode codepoint
()zero-sizethe single value ()

There is no int — every integer type states its width. i32 is the conventional default (what an uncontexted integer literal becomes; floats default to f64). No numeric type converts to another implicitly, not even widening: mixing widths is a compile error until you write the cast (expressions § casts).

char is a full codepoint, deliberately distinct from u8: one character of text may be several bytes of UTF-8. There is no string primitive — a string literal is baked UTF-8 bytes viewed through [*]u8, and the owned text type is String from @core (guide 10).

Storage note: sizeof T is the type's true storage width (sizeof u8 is 1, sizeof i16 is 2, …). Struct fields use the same widths with natural alignment padding between fields; sizeof S.x (a field path) is the size of that field's type. Arrays pack elements at their natural width — a [4]u8 is 4 bytes. Value semantics always follow the declared width: a u8 holds 0…255.

Nominal identity, structural compatibility

Declared structs and enums are nominal: identity is the declaration site, so two declarations with identical fields are different types. Values, however, convert structurally — Vyi's one large implicit conversion is the shape-compatible copy: a struct value flows into another struct type when it provides every field of the target (matched by name, with field types themselves compatible). Extra source fields are dropped; the result is a fresh copy, never an alias.

The conversion is synthesised at every value-flow site — call arguments, return, initialisers, struct-literal fields — and the explicit spelling TargetType(x) is always available:

vyi
import { stdout } from "@io"

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

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

fn main() !u8 {
    con v = Vec3{ x: 1, y: 2, z: 99 }
    con a = sumXY(v)         // argument site: z dropped
    con p: Point = v         // initialiser site
    con q = Point(v)         // explicit form
    stdout.writeInt(i64(a + p.x + q.y))
    stdout.write("\n")
    return Ok(0)
}

The source must provide every target field — a field default fills omitted fields in a literal, not in a conversion:

vyi-error
struct Server { host: i32, port: i32 = 80 }
struct Req    { host: i32 }

fn main() !u8 {
    con r = Req{ host: 1 }
    con s: Server = r      // error: cannot assign Req to Server
    return Ok(0)
}

The same "does the target's constructor accept it" rule powers the other implicit constructions: a member type flows into a union it belongs to, a narrower union into a wider one, and singleton values into their scalar — each described in its own section below. What never converts implicitly: the numeric primitives.

Structs

struct Name { field: Type, … } declares a nominal struct. Fields may carry defaults (port: i32 = 80) and per-field visibility (pub); a literal must account for every field, either explicitly or through its default — there is no implicit zeroing. Guide 03 covers literals, defaults, pub, and the _{ … } inferred literal.

An anonymous struct type — struct { … } in type position — has structural identity: two anonymous struct types with the same field names and types are the same type.

vyi
import { stdout } from "@io"

fn main() !u8 {
    con p: struct { x: i32, y: i32 } = _{ x: 1, y: 2 }
    con q: struct { x: i32, y: i32 } = p    // same type — flows directly
    stdout.writeInt(i64(p.x + q.y))
    stdout.write("\n")
    return Ok(0)
}

A struct type returned by a generic call takes its identity from the call's known arguments: Box(i32) is the same type at every mention, Box(i64) a different one (known values reference).

struct Name { … } is itself shorthand for a type binding — the longhand is type Name = struct { … }. The same holds for enum and interface declarations.

Enums

enum Name { Variant, … } declares a nominal enum: a closed set of uniquely-named variants, each carrying zero or more payload values.

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

enum Shape {
    Dot,                // no payload
    Circle(i32),        // one payload
    Rect(i32, i32),     // several
    Poly(Point),        // aggregate payload
}

Construction is a call on the variant, qualified (Shape.Circle(5)) or bare (Circle(5)) where the expected type already names the enum (expressions § enum construction). Consumption is switch, which is exhaustive over the variants (patterns). ==/!= are built in when every payload is itself equatable; a struct payload makes equality opt-in via @eq (guide 13).

Optional and Result — behind ?T and E!T — are ordinary enums declared in @core; their variants Some/None/Ok/Err are ordinary variant names.

Interfaces

interface Name { fn sig() Ret, … } declares a behavioural shape. Satisfaction is structural and scope-resolved: a type satisfies an interface at a given cast site when methods matching every signature are in scope for it there — there is no implements declaration. Boxing takes a pointer (con s: Speaker = &d) and builds a fat pointer carrying the methods resolved at that site.

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
    stdout.writeInt(i64(s.speak()))
    stdout.write("\n")
    return Ok(0)
}

An interface type goes anywhere a type goes — parameters, fields, returns. Guide 12 develops the model; the dispatch rules live in methods & dispatch.

Tuples

(T, U, …) is a tuple type: positional, undeclared, with structural identity — two tuple types with the same element list are the same type. Elements read by position (.0, .1, chaining as .0.1), and tuples nest anywhere a type goes:

vyi
import { stdout } from "@io"

fn swap(p: (i32, i32)) (i32, i32) {
    return (p.1, p.0)
}

fn main() !u8 {
    con t: ((i32, i32), i64) = ((1, 2), i64(3))
    con s = swap((4, 5))
    stdout.writeInt(i64(t.0.0 + t.0.1 + s.0 + s.1))
    stdout.write("\n")
    return Ok(0)
}

The empty tuple () is the unit type: one value, zero size. A function with no declared return type returns (). There is no void keyword — "nothing" is an ordinary value that carries nothing, so it composes (()!T is a result whose error carries no data).

Structural unions

A | B | C is a structural union: the value is exactly one of the member types, with no wrapper and no variant names — the types themselves are the cases. There is no union keyword; name one with a type binding.

vyi
import { stdout } from "@io"

type Id = i32 | [*]u8

fn describe(id: Id) i32 {
    switch typeof id {
        i32:   { return id },        // id narrows to i32 in this arm
        [*]u8: { return id.len },    // and to [*]u8 here
    }
}

fn main() !u8 {
    stdout.writeInt(i64(describe(41) + describe("x")))
    stdout.write("\n")
    return Ok(0)
}

The rules:

  • Construction is implicit — producing any member type where the union is

expected wraps it (a value of i32 is an i32 | [*]u8).

  • Widening is implicit and subset-checked — a union flows into a wider union

when its members are a subset of the wider one's; the live member is preserved. Anything else is a type error:

```vyi-error fn main() !u8 { con narrow: i32 | u8 = u8(1) con wide: bool | i32 = narrow // error: u8 is not a member of bool | i32 return Ok(0) } ```

  • Consumption dispatches on the member type with switch typeof u (or the

if v: T = u binding form). Inside an arm the value narrows to that member. Switching on the union value directly is rejected — patterns match values, and a union's cases are types.

  • Narrowing is flow-sensitive: a branch that eliminates members refines the

union's static type for the code after it. The full narrowing rules are in patterns § static narrowing.

Unions are also what inferred error sets are made of: bare !T infers the minimal union of the error types the body produces (error model).

Optionals and error unions

SugarDesugars toReads as
?TOptional(T)"a T, or absent"
E!TResult(E, T)"an E, or a T"
!TResult(_, T)"a T, or one of the errors the body actually produces"

All three are type sugar over ordinary @core enums — auto-loaded, no import. Optional(T) has variants Some(T) and None; Result(E, T) has Ok(T) and Err(E). Construction is always explicit (Some(x), Ok(n), Err(e) — a bare T never silently wraps), and consumption is enum consumption: switch, the if Some(v) = o binding form, or the try/catch operators (expressions).

vyi
fn first(xs: [*]i32) ?i32 {
    if xs.len == 0 { return None }
    return Some(xs[0])
}

The sugar and the desugared spelling are the same type and interchange freely:

vyi
import { stdout } from "@io"

enum E { Bad }

fn main() !u8 {
    con o: Optional(i32) = Some(5)
    con o2: ?i32 = o                  // same type
    con r: Result(E, i32) = Ok(7)
    con r2: E!i32 = r                 // same type
    stdout.writeInt(i64((o2 catch 0) + (r2 catch 0)))
    stdout.write("\n")
    return Ok(0)
}

In E!T, the error side E may be any type, including a union — parenthesised when written inline: (NetErr | ParseErr)!i32. The inference rules for bare !T, the same-channel rule for try, and error-set widening are specified in the error model reference; guide 07 is the walkthrough.

An error-only form — bare ! for "can fail, succeeds with nothing" (Result(_, ())) — is implemented: a missing success type defaults to () exactly as a missing error set defaults to the inferred _. Success is Ok(()); see the error model.

Pointers

Every pointer type states its write permission — there is no bare *T:

FormMeaning
*mut Tpointer to a T you may write through
*con Tpointer to a T you may only read through
?*mut T, ?*con Ta pointer that may be absent — ordinary ? over a pointer
c_ptr TC-interop pointer (below)

A *mut T flows implicitly into a *con T slot (dropping write permission is always safe), never the reverse. Pointers cannot be null — absence is ?*T — and support only ==/!=; there is no pointer arithmetic. &x takes an address, *p dereferences, and field access auto-derefs (p.x). Escape is checked: a pointer into the current stack frame may not leave it; stable pointers (allocator memory, globals, reified known values, received parameters) may. The full model is in memory model and guide 09.

vyi
import { stdout } from "@io"

fn bump(p: *mut i32) { *p = *p + 1 }

fn main() !u8 {
    mut x = 41
    con p: *mut i32 = &x
    bump(p)
    con r: *con i32 = p    // *mut widens to *con
    stdout.writeInt(i64(*r))
    stdout.write("\n")
    return Ok(0)
}

c_ptr T is the FFI escape hatch, with raw C semantics: it may be null (address 0), has pointer arithmetic (p + n advances by n strides) and unchecked indexing. It exists for the C boundary and nothing else; you obtain one from a strict pointer or array pointer with @core's cPtrOf, or from C declarations. Guide 15.

Arrays

FormMeaning
[N]TN elements by value; N is part of the type ([3]i32[4]i32)
[*]Tarray pointer: a pointer plus a runtime length, in one value
*mut [N]Tpointer to a fixed array — thin (length in the type, not the value)

[N]T lives by value: assignment copies all elements. The length must be a known value — a runtime length lives in a [*]T, where it is data. Literals spell the type then the elements — [3]i32[10, 20, 12] — and [N]T[] zero-fills.

[*]T is the workhorse view type (a string literal is a [*]u8; allocators hand out [*]T): xs.len is the element count, xs[i] is bounds-checked (negative indices count from the end), xs.ptr exposes the raw pointer for FFI, and range-indexing produces another view over the same storage (expressions § indexing).

The shapes interconvert with the cast spelling; what each direction does:

vyi
import { stdout } from "@io"

fn main() !u8 {
    mut a = [4]u8[1, 2, 3, 4]
    con view = [*]u8(&a)       // *[N]T → [*]T: same storage, length from the type
    con back = *[4]u8(view)    // [*]T → *[N]T: drop .len, trust the type's N
    con copy = [4]u8(view)     // [*]T → [N]T: copies N elements out
    view[0] = 9                // writes land in a; copy is unaffected
    stdout.writeInt(i64(a[0] + copy[0]))  // prints 9 + 1
    stdout.write("\n")
    return Ok(0)
}

Pointer-shape casts re-describe storage and are trusted (*[4]u8(view) believes view.len == 4); the value-array cast copies. The safe "narrow this view" tool is slicing (view[..4]), which is bounds-checked.

Function types

fn(SIG) Ret is the type of a function value — parameter types in parentheses, then the return type (omitted return means ()). Parameter names may be included as labels; they don't affect identity:

vyi
import { stdout } from "@io"

type Reducer = fn(acc: i32, x: i32) i32

fn fold3(f: Reducer, a: i32, b: i32, c: i32) i32 {
    return f(f(a, b), c)
}

fn main() !u8 {
    con add: fn(i32, i32) i32 = (a: i32, b: i32) i32 => a + b
    stdout.writeInt(i64(fold3(add, 20, 10, 12)))
    stdout.write("\n")
    return Ok(0)
}

A top-level fn's name, and an arrow expression, both produce values of this type; closures carry their captured environment inside the value (guide 04). A signature is the identity: two fn(i32) i32 values are the same type whatever they capture. An !T in a function type's return slot must spell its error set — there is no body to infer from (error model).

type and any

type is the meta-type: the type whose values are types. Types are ordinary known values — bind them (type Id = u32, con T: type = i32), pass them to known parameters, return them from functions (that is a generic), and compare or switch over them at runtime. Because type is an open set, a switch over one needs a catch-all any: arm. The known values reference is the authoritative page.

any admits a value of any type — the top type for runtime values. What you can do with an any is dispatch on what it actually is:

vyi
import { stdout } from "@io"

fn kind(v: any) i32 {
    switch typeof v {
        i32:  { return 1 },
        bool: { return 2 },
        any:  { return 0 },    // open set → catch-all required
    }
}

fn main() !u8 {
    stdout.writeInt(i64(kind(5) + kind(true)))
    stdout.write("\n")
    return Ok(0)
}

any is not the inferred-error default: bare !T infers its error set from the body; nothing ever silently erases to any.

Singleton and known-value types

Any known value used in type position denotes the singleton type inhabited by exactly that value — and unions of them give finite, exhaustively-checkable sets:

vyi
import { stdout } from "@io"

type Two = 2
type Mode = "fast" | "slow"

fn speed(m: Mode) i32 {
    switch m {                    // finite set — exhaustive, no catch-all needed
        "fast": { return 9 },
        "slow": { return 1 },
    }
}

fn main() !u8 {
    con t: Two = 2                // only the value 2 inhabits Two
    con n: i32 = t + 39           // widens freely to its scalar
    stdout.writeInt(i64(n + speed("fast") - 9))
    stdout.write("\n")
    return Ok(0)
}

Integer, string, char, and bool values all work (1 | 2, "start" | "stop", 'a' | 'b', true). Assignability is value-based: a known 2 satisfies Two; a runtime i32 does not — membership can't be proven, so it is rejected. A range type lo..hi admits an integer in the half-open interval, checked the same way:

vyi
import { stdout } from "@io"

fn grade(n: 0..101) i32 {
    if hi: 90..101 = n { return 4 }   // runtime bounds check narrows
    return 0
}

fn main() !u8 {
    stdout.writeInt(i64(grade(95)))
    stdout.write("\n")
    return Ok(0)
}

Singleton unions narrow flow-sensitively — ==/!= checks and binding forms subtract members for the code that follows — with the rules in patterns § static narrowing.

The _ inference placeholder

_ in a type slot is a hole the compiler solves. It works in almost any position — parameter and return types, generic arguments at any depth, bindings — and one rule governs it: a _ resolves to the minimal union of the types the code forces into it (one constraint → that type; none → a cannotInferType error at the _).

vyi
import { stdout } from "@io"

fn inc(x: i32) _ {            // return type solved from the body: i32
    return x + 1
}

fn apply(g: fn(i32) i32, x: i32) i32 { return g(x) }

fn main() !u8 {
    con f: fn(i32) i32 = (x: _) _ => x + 1     // both holes solved from the annotation
    con a = apply((y: _) i32 => y * 2, 5)      // solved from apply's parameter
    stdout.writeInt(i64(inc(9) + f(10) + a))  // prints 10 + 11 + 10
    stdout.write("\n")
    return Ok(0)
}

Bare !T is an instance of the same mechanism — _!T with the error hole solved from the body (error model) — and generic type-argument inference is another (max(3, 7) solving T = i32; known values reference). _ also appears in two grammar roles that are not type inference: the inferred struct literal _{ … } (expressions) and the discard binder in patterns (patterns).