Guides · 11
Known values, generics, and compile-time evaluation
The central concept: a known value is a value the compiler computes at compile time.
Everything in this guide — generics, type values, sizeof, dynamic identifiers — is built on
known values, and this is the vocabulary to internalise: when Vyi (or these docs) says a
value is known, it means resolved during compilation, as opposed to a runtime value
that only exists while the program runs.
What "known" means
Literals are known. A con whose initialiser is built from known values is known. The
result of calling an ordinary function on known arguments is known — when that call is
evaluated at compile time. Runtime values are everything else: parameters, mut
bindings, anything read from a file or the network.
import { stdout } from "@io"
con width = 6 // known: a literal
con height = 7 // known: a literal
con area = width * height // known: arithmetic over known values → 42
fn main() !u8 {
mut w = width // a runtime copy of a known value
w = w + 1 // fine — w is a runtime value now
stdout.writeInt(i64(area))
stdout.write("
")
return Ok(0)
}The word known appears in the language itself in two places, and they are two faces of
the same idea:
- as a parameter qualifier —
fn max(known T: type, …)— "this argument must be a known value; I receive it at compile time", and - as a prefix operator —
known expr— "evaluate this expression at compile time, right here".
Both are covered below.
Where known values come from
The compiler contains a real interpreter, and known values are what it produces. It is demand-driven: it evaluates a known value when something needs it, and if the evaluation hits a name that isn't resolved yet, it suspends and comes back once it is. The practical consequence: declarations — functions, types, generics — never need to appear before their first use, in a file or across files.
import { stdout } from "@io"
con answer = known dbl(21) // calls a function declared below
fn dbl(n: i32) i32 { return n * 2 }
con boxed: Box(i32) = _{ value: answer } // uses a generic declared below
fn Box(known T: type) type {
return struct { value: T }
}
fn main() !u8 {
stdout.writeInt(i64(boxed.value))
stdout.write("
")
return Ok(0)
}The order freedom is for declarations (fn, struct, enum,
interface, type). File-scope con and mut bindings are lexical — an initialiser
can only read bindings declared above it, the same rule that governs shadowing inside a
function body.
type is a known value
Types are themselves values — values of the meta-type type — and a type is always a
known value. That one fact powers most of this guide: if types are ordinary known
values, then functions can take them and return them, and that is all a generic is.
Three built-in operators produce type-related known values. typeof expr gives the type
an expression would have — without evaluating it. sizeof T and alignof T give a
type's size and alignment in bytes as i32 constants. Parentheses are optional:
sizeof(T) and sizeof T are the same expression.
import { stdout } from "@io"
struct Header { tag: u8, len: i32 }
fn main() !u8 {
con size: i32 = sizeof(Header) // whole-struct size, padding included
con align: i32 = alignof(Header)
con n = 5
type N = typeof(n) // the type n has: i32
con m: N = 37
stdout.writeInt(i64(size + align + m))
stdout.write("
")
return Ok(0)
}Because typeof produces a type, its result can be bound and used as an annotation —
handy for naming the return type of a function you don't own:
import { stdout } from "@io"
fn add(a: i32, b: i32) i32 { return a + b }
con Sum: type = typeof add(1, 2) // i32 — add(1, 2) is never actually called
fn main() !u8 {
con x: Sum = 42
stdout.writeInt(i64(x))
stdout.write("
")
return Ok(0)
}Generic functions
A generic function is a function with a known type parameter. There is no separate
generics syntax — T is an argument like any other, it just arrives at compile time:
import { stdout } from "@io"
fn max(known T: type, a: T, b: T) T {
if a > b { return a }
return b
}
fn main() !u8 {
con big = max(i32, 3, 7) // explicit type argument → 7
con alsoBig = max(30, 5) // T inferred from the arguments → 30
con hole = max(_, 1, 2) // `_` holds the slot open; solved the same way
stdout.writeInt(i64(big + alsoBig + hole))
stdout.write("
")
return Ok(0)
}All three call forms are the same function. When T appears in the type of a runtime
parameter (a: T), a call may omit it entirely: every runtime value carries its type,
so the compiler solves T from the arguments. Writing _ in a known argument position
does the same thing explicitly — "there's an argument here; infer it."
Inference is structural, so it works when T is nested inside a parameter's type too:
import { stdout } from "@io"
fn firstPlusLen(known T: type, xs: [*]T) i32 {
return i32(xs[0]) + xs.len
}
fn main() !u8 {
con probe = firstPlusLen("hi") // a string is a [*]u8, so T = u8
stdout.writeInt(i64(probe))
stdout.write("
")
return Ok(0)
}There is an even shorter spelling: a free type identifier in a signature makes the
function generic with no known T: type at all —
import { stdout } from "@io"
fn max(a: T, b: T) T {
if a > b { return a }
return b
}
fn main() !u8 {
stdout.writeInt(i64(max(3, 7)))
stdout.write("
")
return Ok(0)
}T isn't declared anywhere, so it is read as an implicit type parameter, inferred at
every call site.
A known parameter demands a known argument. Passing something the compiler can't
resolve at compile time is an error:
fn need(known T: type, x: T) T {
return x
}
fn main() !u8 {
mut t = i32 // a mut can be reassigned — its value isn't known
con r = need(t, 3) // error: argument to known parameter "T" is not known
return Ok(0)
}Monomorphization
For each distinct tuple of known arguments, the compiler produces one concrete compiled
copy of the function — a monomorph. max(i32, …) and calls that infer T = i32 share
one copy; T = i64 gets another. Runtime arguments never affect this — only the known
ones do.
Generic types are functions that return types
Since a type is a known value, a "generic type" is just a function from types to types:
import { stdout } from "@io"
fn Box(known T: type) type {
return struct { value: T }
}
type IntBox = Box(i32) // bind an instantiation to a name
fn main() !u8 {
con a = Box(i32){ value: 40 }
con b: IntBox = _{ value: 2 } // IntBox and Box(i32) are the SAME type
stdout.writeInt(i64(a.value + b.value))
stdout.write("
")
return Ok(0)
}Instantiation is memoized per known-argument tuple: every mention of Box(i32) —
here, in another file, through the IntBox alias — evaluates to the same type, so
values flow freely between them. Box(i64) is a distinct type with its own layout.
(This is the type-level face of monomorphization: same known arguments, same identity.)
The body doesn't have to be a bare struct literal — it's an ordinary function body
evaluated at compile time, so it can compute:
import { stdout } from "@io"
fn Cfg(known T: type) type {
if sizeof T > 4 { return struct { wide: T } }
return struct { narrow: T }
}
fn main() !u8 {
con a = Cfg(i64){ wide: 30 } // sizeof i64 = 8 → the `wide` shape
con b = Cfg(u8){ narrow: 7 } // sizeof u8 = 1 → the `narrow` shape
stdout.writeInt(i64(i32(a.wide) + i32(b.narrow)))
stdout.write("
")
return Ok(0)
}Enums and interfaces work the same way — `fn Maybe(known T: type) type { return enum { Nothing, Just(T) } }` is a generic enum, and guide 12 builds generic interfaces with the identical pattern.
known parameters can be values, not just types
Any type can qualify as known, not only type. Each distinct known value then gets
its own monomorph — compile-time specialisation of the function body:
import { stdout } from "@io"
fn scale(known factor: i32, x: i32) i32 {
return factor * x
}
fn main() !u8 {
stdout.writeInt(i64(scale(3, 4) + scale(10, 5)))
stdout.write("
")
return Ok(0) // 12 + 50; two monomorphs
}The factor * x in the first copy is compiled as 3 * x. Use this for genuinely fixed
configuration — every distinct known argument is another compiled copy.
The known operator
The prefix operator known expr forces an expression to be evaluated at compile time.
"Known" is a property of a call, not a species of function — the same ordinary
function can be folded at compile time in one place and called at runtime in another:
import { stdout } from "@io"
fn dbl(n: i32) i32 { return n * 2 }
fn main() !u8 {
con folded = known dbl(3) // evaluated during compilation: 6
mut x = 15
con later = dbl(x) // the same function, called while running
stdout.writeInt(i64(folded + later + 1))
stdout.write("
")
return Ok(0)
}There is also a block form, known { … }, whose value is the block's tail expression:
import { stdout } from "@io"
con total = known {
mut acc = 0
for i in 0...10 { acc = acc + i }
acc
}
fn main() !u8 {
stdout.writeInt(i64(total)) // 55, computed before the program exists
stdout.write("
")
return Ok(0)
}Everything a known expression touches must itself be known — writing known dbl(x)
with a runtime x is rejected, for the same reason a runtime argument can't feed a
known parameter: the value simply doesn't exist yet when the evaluation runs.
Notice the mut acc inside the block above: mut is fine inside a known evaluation,
because it stays local to that one evaluation. What comes out is a finished, immutable
known value — more on this under stability.
Dynamic identifiers
A name slot — a declaration name, a struct field, an enum variant, a field access — can
be written as [expr], a dynamic identifier. The expression inside the brackets is
evaluated at compile time, like a known value is, and must reduce to a string; that
string becomes the name:
import { stdout } from "@io"
con fieldX = "x"
con fieldY = "y"
struct Point {
[fieldX]: i32, // declares a field named x
[fieldY]: i32,
}
fn main() !u8 {
con p = Point{ [fieldX]: 7, [fieldY]: 35 }
stdout.writeInt(i64(p.[fieldX] + p.y))
stdout.write("
")
return Ok(0) // both spellings reach the same field
}It works uniformly in every name slot — here an enum variant:
import { stdout } from "@io"
con opName = "Add"
enum Op {
[opName],
Sub,
}
fn main() !u8 {
con o = Op.Add
con n = switch o { Add: 1, Sub: 2 }
stdout.writeInt(i64(n))
stdout.write("
")
return Ok(0)
}Declaration names take it too (con [name] = …, struct [sname] { … }), which is what
makes the compile-time for loop below able to stamp out families of declarations.
Compile-time control flow
Control flow participates in compile-time evaluation at two levels.
First, inside expressions: an if whose condition is known folds automatically — the
compiler selects the live branch and the dead one never exists:
import { stdout } from "@io"
con n = if 7 > 3 { 100 } else { 200 } // known-true → n is 100; 200 is never built
fn main() !u8 {
stdout.writeInt(i64(n))
stdout.write("
")
return Ok(0)
}Second, at file scope, if and for are directives: they run during compilation
and decide which declarations exist. A file-scope if keeps one branch's
declarations and drops the other's:
import { stdout } from "@io"
con wasm = true
if wasm {
fn pageSize() i32 { return 64 }
} else {
fn pageSize() i32 { return 4 }
}
fn main() !u8 {
stdout.writeInt(i64(pageSize()))
stdout.write("
")
return Ok(0) // 64 — only the wasm branch was declared
}A file-scope for unrolls over a known iterable, running its body once per element.
Combined with dynamic identifiers, each iteration can declare a differently-named
binding:
import { stdout } from "@io"
for name in _["red", "green", "blue"] {
con [name] = 10 // declares con red, con green, con blue
}
fn main() !u8 {
stdout.writeInt(i64(red + green + blue))
stdout.write("
")
return Ok(0) // 30
}Because directives run before the program does, their conditions must be known values — a runtime value can't decide what gets compiled:
mut counter: i32 = 0
if counter > 0 { // error: not a known-reducible condition
fn helper() i32 { return 1 }
}
fn main() !u8 { return Ok(0) }Values as types
A type isn't only a compile-time citizen — it can also be a first-class runtime
value. The place you meet this is union types: typeof over a value of a union type
asks which member is live right now, which is a runtime question, and switch over a
type dispatches on the answer:
import { stdout } from "@io"
fn pick(flag: bool) i32 | bool {
if flag { return 7 }
return false
}
fn describe(t: type) i32 { // a runtime `type` parameter
switch t {
i32: { return 1 },
bool: { return 2 },
any: { return 0 }, // `type` is open-ended — a catch-all is required
}
}
fn main() !u8 {
con u = pick(true)
mut r = 0
switch typeof u { // runtime: which member is u holding?
i32: { r = r + 1 },
bool: { r = r + 2 },
}
r = r + describe(typeof u)
stdout.writeInt(i64(r))
stdout.write("
")
return Ok(0)
}The same typeof is compile-time when the answer is knowable at compile time (a
concrete, non-union operand) and runtime when it isn't. You don't choose a mode — the
operand decides.
Stability of known values
A pointer is stable when what it points at outlives every stack frame — stable
pointers may be returned, stored in long-lived structures, and captured by closures.
Known values have a special relationship with stability: a known value can be reified
— given real storage baked into the compiled program. Take the address of a file-scope
con and that is exactly what happens, so the pointer is stable:
import { stdout } from "@io"
con limit: i32 = 100
fn limitPtr() *con i32 {
return &limit // stable: limit is baked into the program itself
}
fn main() !u8 {
stdout.writeInt(i64(*limitPtr()))
stdout.write("
")
return Ok(0) // 100
}A frame-local is the opposite — its storage dies with the function — so letting its
address escape is rejected, con or not:
fn dangling() *con i32 {
con local: i32 = 42
return &local // error: frame-bound pointer escapes via return
}
fn main() !u8 {
return Ok(0)
}This is also why mut inside a known { … } block is fine: the mutation is local to
that single compile-time evaluation and can't escape it. The moment the result leaves
the block, it is an ordinary immutable known value — and if it's reified, a stable one.
See also: reference/compile time.