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:
| Source | Known? |
|---|---|
literals (42, "hi", 'a', true, ()) | yes |
a con whose initialiser is built from known values | yes |
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 itself | yes — a type is always a known value |
typeof / sizeof / alignof results over compile-time operands | yes |
parameters (non-known), mut bindings, anything read at runtime | no |
The word known appears in the language in two places, two faces of the same idea:
- a parameter qualifier —
fn f(known T: type, …)— "this argument arrives at
compile time";
- a prefix operator —
known 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, andtype
declarations may reference each other in any order, in a file or across files.
- File-scope initialisers read upward only. File-scope
conandmutbindings
are lexical: an initialiser may only read bindings declared above it — the same rule that governs shadowing inside a function body.
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)
}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:
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`:
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:
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):
| Operator | Operand | Result |
|---|---|---|
typeof expr | any expression — not evaluated | the type the expression would have |
sizeof T | a type | the type's size in bytes, as an i32 known value |
alignof T | a type | the type's alignment in bytes, as an i32 known value |
Sizes and alignments are target-dependent; sizeof/alignof are how programs ask
(memory model).
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.)
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).
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:
| Slot | Example |
|---|---|
| declaration names | con [name] = …, fn [name]() { … }, struct [name] { … } |
| struct fields | struct S { [f]: i32 } and the initialiser S{ [f]: 1 } |
| enum variants | enum E { [v], Other } |
| member access | s.[f] |
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`).
| Directive | Effect |
|---|---|
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 |
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)
}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:
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.
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.
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.