Compile-Time Functions
GX can run real functions at compile time — loops, conditionals, recursion, the whole thing. The result gets baked into your binary as literals, with zero runtime cost.
Compile-Time Functions with #fn
Prefix a function declaration with #fn to mark it as compile-time:
#fn factorial_compile:i32(n:i32) {
if (n <= 1) { return 1 }
return n * factorial_compile(n - 1)
}
// Evaluated at COMPILE time, not runtime
const FACT_5 = factorial_compile(5)
const FACT_10 = factorial_compile(10)
fn main() {
print("5! = {FACT_5}\n") // compiler emits 120
print("10! = {FACT_10}\n") // compiler emits 3628800
}
The generated C contains 120 and 3628800 as literal constants. The factorial_compile function itself doesn’t exist in the output — it was executed by the compiler.
Compile-Time Loops with #for
#for unrolls loops at compile time:
fn main() {
#for (i = 0:4) {
print("compile-time iteration {i}\n")
}
}
The generated C has four separate print calls — the loop is gone. This is different from a regular for which generates a runtime loop.
Compile-Time Conditionals in Function Bodies
Inside a normal function, #if lets you include or exclude lines based on compile-time conditions:
fn log(msg:str) {
#if (@debug) {
print("[LOG] {msg}\n")
}
// In release builds, this function body is empty
}
fn main() {
log("starting up")
log("initializing")
log("ready")
}
In a debug build, every log call prints. In a release build, the function is empty, and the C compiler will often inline-away every call.
Combining #fn with Constants
Compile-time functions can compute complex constants:
#fn fibonacci:i32(n:i32) {
if (n <= 1) { return n }
return fibonacci(n - 1) + fibonacci(n - 2)
}
const FIB_10 = fibonacci(10) // computed during compilation
const FIB_20 = fibonacci(20)
fn main() {
print("fib(10) = {FIB_10}\n") // 55
print("fib(20) = {FIB_20}\n") // 6765
}
The recursive calls happen in the compiler, not at runtime. Calling fibonacci(20) at runtime would take millions of calls; here it’s one constant lookup.
Lookup Tables at Compile Time
A common pattern: build a table during compilation that would be slow to compute at runtime:
#fn square:i32(n:i32) {
return n * n
}
fn main() {
// Each element is evaluated at compile time
var squares:i32[8] = {
square(0), square(1), square(2), square(3),
square(4), square(5), square(6), square(7)
}
for (var s in squares) {
print("{s} ")
}
print("\n")
}
The generated C declares squares[8] with literal values {0, 1, 4, 9, 16, 25, 36, 49} — no runtime math.
Compile-Time if Selects Between Values
#if can be used as an expression to pick values based on compile target:
#if (@os == "windows") {
const PATH_SEP = "\\"
}
#if (@os != "windows") {
const PATH_SEP = "/"
}
fn main() {
print("path separator: '{PATH_SEP}'\n")
}
Only one of the two const PATH_SEP declarations appears in the generated code, based on the target OS.
Practical Example: Baked Physics Constants
const GRAVITY = 9.81
const AIR_DENSITY = 1.225
const DRAG_COEFFICIENT = 0.47
#fn terminal_velocity:f32(mass:f32, area:f32) {
// v = sqrt(2mg / (ρCA))
// Simplified for demonstration
var num = 2.0 * mass * GRAVITY
var den = AIR_DENSITY * DRAG_COEFFICIENT * area
// Square root approximation for compile time
var guess = num / den
var i = 0
while (i < 10) {
guess = (guess + (num / den) / guess) * 0.5
i = i + 1
}
return guess
}
// Computed at compile time!
const SPHERE_TV = terminal_velocity(1.0, 0.01)
fn main() {
print("terminal velocity (1kg, 0.01m²): {SPHERE_TV} m/s\n")
}
The square root loop runs during compilation. The result is embedded as a literal.
Try it — Write a
#fn power:i32(base:i32, exp:i32)and use it to declare a const lookup table of powers of 2. Run in the Playground.
Expert Corner
How #fn runs: GX has a compile-time interpreter that executes #fn functions on the AST directly, before code generation. It supports arithmetic, loops, conditionals, recursion, and local variables. It does NOT support: allocators, file I/O, system calls, or external C functions. The intent is pure, deterministic computation — producing values, not side effects.
What qualifies as compile-time: A function must be declared #fn to be callable from constant contexts. Regular fn functions cannot be used in const initializers, even if they look pure. This explicitness makes it obvious which code runs at compile time and which at runtime — no hidden surprises from “sufficiently simple” functions being folded.
Zig comparison: Zig uses the same function for both compile-time and runtime, decided by the call context (comptime foo()). GX uses explicit #fn to separate them. Zig’s approach is more flexible; GX’s is more predictable — you can tell at a glance which functions run when.
Compile-time limits: The interpreter has limits on iterations to prevent infinite loops from hanging the compiler. If your #fn has a loop that never terminates, the compiler will give up and report an error. In practice, ~100,000 iterations is the soft limit for compile-time evaluation.
#for vs regular for: #for is loop unrolling. The bounds must be compile-time constants. The body is emitted N times into the output. Use it for small, known-count loops where you want to avoid runtime loop overhead, or for generating N similar statements.
Why this matters for systems programming: Compile-time evaluation lets you precompute lookup tables, configuration values, and derived constants. Things that would be baked into header files via macros in C (with all the macro footguns) or constexpr in C++ (with all the constraints) can be written as normal GX code with #fn. The result is smaller binaries, faster startup, and cleaner source than the C equivalent.