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.

vyi
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 qualifierfn max(known T: type, …) — "this argument must be a known value; I receive it at compile time", and
  • as a prefix operatorknown 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.

vyi
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.

vyi
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:

vyi
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:

vyi
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:

vyi
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 —

vyi
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:

vyi-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:

vyi
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:

vyi
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:

vyi
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:

vyi
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:

vyi
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:

vyi
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:

vyi
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:

vyi
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:

vyi
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:

vyi
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:

vyi-error
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:

vyi
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:

vyi
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:

vyi-error
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.