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
| Form | Meaning |
|---|---|
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 | B | structural union — one of the listed types |
?T | optional — Optional(T) |
E!T | error union — Result(E, T) |
!T | error union with inferred error set — Result(_, T) |
*mut T, *con T | pointer to one T (writable / read-only) |
[N]T | fixed array of N elements; N is part of the type |
[*]T | array pointer — pointer plus runtime length |
c_ptr T | C-interop pointer: nullable, with pointer arithmetic |
fn(SIG) Ret | function type |
type | the meta-type: the type whose values are types |
any | admits a value of any type |
42, "go", 'a', true | singleton type — inhabited by exactly that value |
0..101 | range type — an integer within the half-open interval |
_ | inference placeholder — solved by the compiler |
Primitive types
| Type | Width | Values |
|---|---|---|
i8 | 8-bit signed | −128 … 127 |
i16 | 16-bit signed | −32 768 … 32 767 |
i32 | 32-bit signed | −2 147 483 648 … 2 147 483 647 |
i64 | 64-bit signed | −2⁶³ … 2⁶³−1 |
u8 | 8-bit unsigned | 0 … 255 |
u16 | 16-bit unsigned | 0 … 65 535 |
u32 | 32-bit unsigned | 0 … 4 294 967 295 |
u64 | 64-bit unsigned | 0 … 2⁶⁴−1 |
f32 | IEEE 754 binary32 | single-precision float |
f64 | IEEE 754 binary64 | double-precision float |
bool | — | true, false |
char | 32-bit | one Unicode codepoint |
() | zero-size | the 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:
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:
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.
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.
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.
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:
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.
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
| Sugar | Desugars to | Reads as |
|---|---|---|
?T | Optional(T) | "a T, or absent" |
E!T | Result(E, T) | "an E, or a T" |
!T | Result(_, 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).
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:
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:
| Form | Meaning |
|---|---|
*mut T | pointer to a T you may write through |
*con T | pointer to a T you may only read through |
?*mut T, ?*con T | a pointer that may be absent — ordinary ? over a pointer |
c_ptr T | C-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.
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
| Form | Meaning |
|---|---|
[N]T | N elements by value; N is part of the type ([3]i32 ≠ [4]i32) |
[*]T | array pointer: a pointer plus a runtime length, in one value |
*mut [N]T | pointer 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:
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:
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:
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:
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:
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 _).
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).