Docs · Language

Known values & compile-time evaluation

A known value is a value the compiler computes at compile time. "Known" is the canonical term used throughout Vyi and these docs; this page is the precise reference for what is known, how known values are produced and demanded, and how generics, type values, dynamic identifiers, and stability build on them. For the tutorial treatment read guide 11 — Known values, generics & compile-time evaluation.

Known values — definition and sources

A value is known when the compiler can fully resolve it during compilation; it is a runtime value otherwise. The sources:

SourceKnown?
literals (42, "hi", 'a', true, ())yes
a con whose initialiser is built from known valuesyes
the result of calling a function on known arguments, when that call is evaluated at compile time (known f(x), a known-parameter slot, a type-level call)yes
every type, including type itselfyes — a type is always a known value
typeof / sizeof / alignof results over compile-time operandsyes
parameters (non-known), mut bindings, anything read at runtimeno

The word known appears in the language in two places, two faces of the same idea:

  • a parameter qualifierfn f(known T: type, …) — "this argument arrives at

compile time";

  • a prefix operatorknown expr / known { … } — "evaluate this here, at

compile time".

A known evaluation may only touch other known values. Feeding it a runtime value is rejected — the value does not exist yet when the evaluation runs.

Evaluation is on demand

Known values are produced by a real interpreter inside the compiler, and it is demand-driven: a known value is evaluated when something needs it, and an evaluation that reaches a name not yet resolved suspends and resumes once it is. Two ordering rules fall out:

  • Declarations need no order. fn, struct, enum, interface, and type

declarations may reference each other in any order, in a file or across files.

  • File-scope initialisers read upward only. File-scope con and mut bindings

are lexical: an initialiser may only read bindings declared above it — the same rule that governs shadowing inside a function body.

vyi
import { stdout } from "@io"

con answer = known dbl(21)      // fine: dbl is a declaration, declared below

fn dbl(n: i32) i32 { return n * 2 }

fn main() !u8 {
    stdout.writeInt(i64(answer))
    stdout.write("\n")
    return Ok(0)
}
vyi-error
con a = b + 1       // error: undefined name "b" — initialisers read upward only
con b = 1

fn main() !u8 { return Ok(0) }

known parameters

known before a parameter name requires the argument to be a known value; the function receives it at compile time. Any type qualifies — type is the common case (that is what a generic is), but plain values work identically:

vyi
import { stdout } from "@io"

fn max(known T: type, a: T, b: T) T {     // known type parameter
    if a > b { return a }
    return b
}

fn scale(known factor: i32, x: i32) i32 { // known value parameter
    return factor * x                      // compiled as a constant multiply per factor
}

fn main() !u8 {
    con a = max(i32, 3, 7)     // explicit type argument
    con b = max(30, 5)         // T inferred from the runtime arguments
    con c = max(_, 1, 2)       // `_` holds the slot open; solved the same way
    stdout.writeInt(i64(a + b + c + scale(3, 4) - 51))  // prints 7 + 30 + 2 + 12 - 51 = 0
    stdout.write("\n")
    return Ok(0)
}

When a known type parameter appears in a runtime parameter's type (directly or nested, as in xs: [*]T), calls may omit it: every runtime value carries its type, and the argument is solved structurally. A free type identifier in a signature is the shorthand form — it declares an implicit known type parameter, inferred at every call site.

A runtime argument in a known slot is rejected with `argument to known parameter "factor" is not known at compile time`:

vyi-error
fn scale(known factor: i32, x: i32) i32 { return factor * x }

fn main() !u8 {
    mut f = 3
    return Ok(0))   // error: argument to known parameter is not known
}

The known operator

known expr evaluates an expression at compile time, right there; known { … } is the block form, whose value is the block's tail expression. "Known" is a property of the evaluation, not of the function — the same ordinary function may be folded at compile time at one call site and called at runtime at another:

vyi
import { stdout } from "@io"

fn fib(n: i32) i32 {
    if n < 2 { return n }
    return fib(n - 1) + fib(n - 2)
}

con f10 = known fib(10)     // 55, computed during compilation

con total = known {         // block form: statements, loops, mut — then a tail value
    mut acc = 0
    for i in 0...10 { acc = acc + i }
    acc
}

fn main() !u8 {
    mut n = 5
    con atRuntime = fib(n)              // same function, run while the program runs
    stdout.writeInt(i64(f10 + total - 110 + atRuntime))  // prints 55 + 55 - 110 + 5 = 5
    stdout.write("\n")
    return Ok(0)
}

Known-mut locality. mut inside a known evaluation is legal — the mutation is local to that single evaluation and cannot escape it. What leaves the evaluation is a finished, immutable known value. A runtime value can never flow in: `known fib(n) with a runtime n` is rejected, for the same reason a runtime argument cannot feed a known parameter — the value does not exist yet when the evaluation runs.

type, typeof, sizeof, alignof

type is the meta-type: the type whose values are types. A type is always a known value, so types can be bound (type Id = u32), passed to known parameters, and returned from functions. Three prefix operators produce type-related known values (parentheses optional — sizeof T and sizeof(T) are the same expression):

OperatorOperandResult
typeof exprany expression — not evaluatedthe type the expression would have
sizeof Ta typethe type's size in bytes, as an i32 known value
alignof Ta typethe type's alignment in bytes, as an i32 known value

Sizes and alignments are target-dependent; sizeof/alignof are how programs ask (memory model).

vyi
import { stdout } from "@io"

struct Header { tag: u8, len: i32 }

fn add(a: i32, b: i32) i32 { return a + b }

con Sum: type = typeof add(1, 2)    // i32 — add is never called

fn main() !u8 {
    con size: i32 = sizeof(Header)
    con align: i32 = alignof Header
    con x: Sum = 40
    stdout.writeInt(i64(x + size - size + align - align + 2))  // prints 42
    stdout.write("\n")
    return Ok(0)
}

Generics, monomorphization, and memoized identity

A generic declaration is a function with known parameters; a generic type is a function returning type. The identity rules:

  • One monomorph per known-argument tuple. For each distinct tuple of known

arguments, the compiler produces one concrete compiled copy of the function. Runtime arguments never affect this — only the known ones do.

  • Instantiation is memoized on the known-argument values. Every mention of

Box(i32) — in any file, through any alias — evaluates to the same type; values flow freely between spellings. Box(i64) is a distinct type with its own layout. (?i32 and Optional(i32) are the same type by exactly this rule.)

vyi
import { stdout } from "@io"

fn Box(known T: type) type {
    return struct { value: T }
}

type IntBox = Box(i32)      // an alias, not a new type

fn main() !u8 {
    con a = Box(i32){ value: 40 }
    con b: IntBox = a       // same type: values flow between spellings
    stdout.writeInt(i64(b.value + 2))
    stdout.write("\n")
    return Ok(0)
}

The body of a type-returning function is ordinary code evaluated at compile time — it may branch on sizeof T, loop, and compute before returning a struct, enum, or interface (guide 11).

Values as types

A type can also be a first-class runtime value: union types make "which member is live?" a runtime question. typeof over a concrete operand is a known value; typeof over a union-typed value is answered at runtime — the operand decides, not a mode you choose. switch over a type dispatches on it; because type is open-ended, a catch-all any arm is required (guide 11).

vyi
import { stdout } from "@io"

fn pick(flag: bool) i32 | bool {
    if flag { return 7 }
    return false
}

fn main() !u8 {
    con u = pick(true)
    mut r = 0
    switch typeof u {      // runtime: which member is u holding now?
        i32:  { r = 1 },
        bool: { r = 2 },
    }
    stdout.writeInt(i64(r))
    stdout.write("\n")
    return Ok(0)
}

Dynamic identifiers

Any name slot may be written [expr] — a dynamic identifier. The bracketed expression is evaluated at compile time, like a known value, and must reduce to a known string; that string becomes the name. The slots:

SlotExample
declaration namescon [name] = …, fn [name]() { … }, struct [name] { … }
struct fieldsstruct S { [f]: i32 } and the initialiser S{ [f]: 1 }
enum variantsenum E { [v], Other }
member accesss.[f]
vyi
import { stdout } from "@io"

con fieldX = "x"
con fname = "triple"

struct Point { [fieldX]: i32, y: i32 }

fn [fname](n: i32) i32 { return n * 3 }

fn main() !u8 {
    con p = Point{ [fieldX]: 7, y: 5 }
    stdout.writeInt(i64(p.x + p.[fieldX] + triple(p.y)))  // prints 7 + 7 + 15 = 29
    stdout.write("\n")
    return Ok(0)
}

Dynamic identifiers are what let file-scope for directives stamp out families of declarations, below.

File-scope directives: if and for

At file scope, if and for run during compilation and decide which declarations exist. Their conditions and iterables must be known values — a runtime value cannot decide what gets compiled (the rejection is `not a known-reducible condition`).

DirectiveEffect
if cond { decls } else { decls }keeps the live branch's declarations; the dead branch is never built
for x in knownIterable { decls }unrolls: the body's declarations are produced once per element, with x bound as a known value in each
vyi
import { stdout } from "@io"

con wasm = true

if wasm {
    fn pageSize() i32 { return 64 }
} else {
    fn pageSize() i32 { return 4 }
}

for name in _["red", "green", "blue"] {
    con [name] = 10        // declares con red, con green, con blue
}

fn main() !u8 {
    stdout.writeInt(i64(pageSize() + red + green + blue))  // prints 64 + 30 = 94
    stdout.write("\n")
    return Ok(0)
}
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) }

Inside expressions the same folding happens automatically: an if whose condition is known keeps only the live branch, no directive syntax needed — in statement position and in value position. A value-position if with a known-true condition therefore needs no else (the fold selects the then-value before the else requirement ever applies), and a known condition with an else drops the dead branch entirely, so it may hold target-specific code that would not compile here:

vyi
import { targetOS } from "@target"
import { stdout } from "@io"

fn main() !u8 {
    con page = if targetOS == "wasm" { 64 } else { 4 }   // per-target value
    con bonus = if 7 > 3 { 30 }        // known TRUE: folds, no else needed
    stdout.writeInt(i64(page - page + bonus + 12))  // prints 42
    stdout.write("\n")
    return Ok(0)
}

The one form that stays an error is a value-position if with a known-false condition and no else: there is no branch to fold to, so no value exists.

vyi-error
fn main() !u8 {
    con v = if 1 > 3 { 10 }   // error: if-expression requires an else branch
    return Ok(0)
}

Stability of known values

A known value can be reified — given real storage baked into the compiled program's data. Pointing at a file-scope con does exactly that, so the pointer is stable: it may be returned, stored, and captured, because the storage outlives every stack frame. String literals are the everyday case — a "" literal is a [*]u8 view over its baked bytes.

A frame-local is not reified by being known: its address dies with the frame, and letting it escape is rejected. The full stability rules — stable sources, escape checking, the allocator's role — live in the memory model.

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

Known-mut locality is the write-side counterpart: mutation is confined to the inside of one known evaluation, so every known value a program can point at is immutable, finished data — which is what makes reifying it sound.

See also: guide 11 — Known values, generics & compile-time evaluation · memory model · types.