Templates & Metaprogramming
Nostos provides a compile-time metaprogramming system based on templates. This tutorial walks through progressively more complex examples.
Core Concepts
quote { expr }- Capture code as AST data~expr- Splice: insert AST valuestemplate name(params) = ...- Compile-time functions
Example 1: Simple Function Wrapper
The most basic use - wrap a function body with extra behavior:
template double(fn) = quote {
result = ~fn.body
result * 2
}
@double
getValue() = 21
main() = getValue() # Returns 42
The decorator receives the full function and uses ~fn.body to splice in the original body.
Example 2: Accessing Function Metadata
Templates can inspect function name, parameters, and return type:
template debugCall(fn) = quote {
println("Calling: " ++ ~fn.name)
result = ~fn.body
println("Done: " ++ ~fn.name)
result
}
@debugCall
add(a: Int, b: Int) = a + b
main() = add(1, 2)
# Output:
# Calling: add
# Done: add
# 3
Available fields:
~fn.name- Function name as String~fn.params- List of {name, type} records~fn.body- Function body AST~fn.returnType- Return type as String
Example 3: Memoization Decorator
A @memoize decorator that automatically caches function results:
# Thread-safe cache using mvar (handles concurrent access automatically)
mvar cache: Map[Int, Int] = %{}
# Cache helper functions using UFC
getCache(key: Int) -> Int =
if cache.contains(key) { cache.get(key) } else { 0 }
setCache(key: Int, value: Int) -> Int = {
cache.update(c => c.insert(key, value))
value
}
hasCache(key: Int) -> Bool = cache.contains(key)
# The memoize decorator uses the cache functions
template memoize(fn) = quote {
key = ~param(0)
if hasCache(key) { getCache(key) }
else { setCache(key, ~fn.body) }
}
@memoize
fib(n: Int) -> Int = if n < 2 { n } else { fib(n - 1) + fib(n - 2) }
main() = fib(40) # Returns 102334155 instantly
Key points:
- •
mvar cache- Thread-safe mutable state (concurrent access handled automatically) - •
cache.contains(),cache.get()- UFC works with mvars - •
cache.update(c => c.insert(...))- Atomic read-modify-write with UFC in lambda - •
~param(0)- Reference function parameter as variable (shorthand for~toVar(fn.params[0].name))
Example 4: Type Decorator with Getter Generation
Generate accessor functions for all fields of a type:
template withGetters(typeDef) = quote {
~typeDef
~typeDef.fields[0].fields.map(f =>
eval("get_" ++ f.name ++ "(r: " ++ ~typeDef.name ++ ") = r." ++ f.name)
)
}
@withGetters
type Point = Point { x: Int, y: Int }
main() = {
p = Point(x: 10, y: 20)
get_x(p) + get_y(p) # 30
}
This generates get_x(r: Point) = r.x and get_y(r: Point) = r.y.
Example 5: Builder Pattern with Setters
Generate both getters and setters:
template withAccessors(typeDef) = quote {
~typeDef
# Generate getters
~typeDef.fields[0].fields.map(f =>
eval("get_" ++ f.name ++ "(r: " ++ ~typeDef.name ++ ") = r." ++ f.name)
)
# Generate setters (return new instance)
~typeDef.fields[0].fields.map(f =>
eval("set_" ++ f.name ++ "(r: " ++ ~typeDef.name ++ ", v: " ++ f.type ++ ") = " ++
~typeDef.name ++ "(" ++ f.name ++ ": v)")
)
}
@withAccessors
type Config = Config { timeout: Int, retries: Int }
main() = {
c = Config(timeout: 30, retries: 3)
c2 = set_timeout(c, 60)
get_timeout(c2) # 60
}
Example 6: Compile-Time Feature Flags
Use templates to enable/disable features at compile time:
# Compile-time check - generates different code based on flag
template featureFlag(fn, enabled, errorMsg) = quote {
~if ~enabled {
quote { ~fn.body }
} else {
quote { panic(~errorMsg) }
}
}
@featureFlag(true, "Beta feature disabled")
betaFeature() = "Beta feature is active!"
@featureFlag(false, "Experimental feature disabled")
experimentalFeature() = "Never runs"
main() = betaFeature() # Works: "Beta feature is active!"
# experimentalFeature() would panic: "Experimental feature disabled"
This pattern is useful for feature flags, debug-only code paths, and platform-specific implementations.
Example 7: Runtime Parameter Validation
Use ~param(n) to reference the n-th parameter of the decorated function:
# ~param(0) is shorthand for ~toVar(fn.params[0].name)
template nonNegative(fn) = quote {
if ~param(0) < 0 {
panic("Value must be non-negative")
}
~fn.body
}
@nonNegative
mySqrt(n: Int) = n * n
main() = {
mySqrt(4) # Returns 16
# mySqrt(-5) # Would panic: "Value must be non-negative"
}
~param(n) is a shorthand for ~toVar(fn.params[n].name). The underlying toVar function converts a string to a variable reference, which is useful for more complex dynamic code generation.
Example 8: Unique Variable Names with Gensym
Use gensym to avoid naming collisions in generated code:
template withTempVar(typeDef) = quote {
~typeDef
~eval(~gensym("temp") ++ "_helper() = 42")
}
@withTempVar
type A = A {}
@withTempVar
type B = B {}
# Generates: temp_0_helper() and temp_1_helper()
# No naming collision!
Each call to gensym produces a unique string like "prefix_0", "prefix_1", etc.
Example 9: Compile-Time Code Execution
Use comptime to execute arbitrary Nostos code at compile time. Two syntaxes are supported:
String syntax: comptime("code")
template withComputedDefault(fn, multiplier) = quote {
# Compute at compile time: 21 * 2 = 42
defaultValue = ~comptime("21 * " ++ ~multiplier)
~fn.body + defaultValue
}
@withComputedDefault("2")
getValue(x: Int) = x
main() = getValue(0) # Returns 42
Block syntax: comptime({ block })
template computed(fn, useSquare) = quote {
result = ~comptime({
base = 10
if ~useSquare {
base * base
} else {
base * 2
}
})
result
}
@computed(true)
getSquare() = 0
main() = getSquare() # 10 * 10 = 100
The comptime function:
- String syntax: Takes a String that gets evaluated
- Block syntax: Takes a block
({ ... })that gets serialized and evaluated - Executes the code at compile time
- Splices the result into the template
This is useful for pre-computing values, lookup tables, or any computation that can be done once at compile time. Note: comptime executes in a minimal environment without stdlib. Use it for basic arithmetic, string operations, and simple computations.
Type Introspection Reference
For type decorators:
~typeDef.name- Type name as String~typeDef.fields- Constructors (for variants) or fields (for records)~typeDef.fields[0].fields- Fields of first constructor~typeDef.typeParams- Generic type parameters
Each field has:
f.name- Field namef.type- Field type as String
AST Types Reference
Literals
Int, Float, String, Bool, Char, Unit
Identifiers
Var
Expressions
BinOp, UnaryOp, Call, MethodCall, FieldAccess, Index, Lambda, Block, If, Match, Let
Collections
List, Tuple, Record, Map
Patterns
PatternWildcard, PatternVar, PatternLit, PatternTuple, PatternList, PatternConstructor
Definitions
FnDef, TypeDef, TraitImpl, Items
Template-specific
Splice
Best Practices
- Start simple - Get basic templates working before adding complexity
- Test incrementally - Verify each generated function works
- Use meaningful names -
fn,typeDefare conventional for decorators - Document what's generated - Comment the expected output
- Handle edge cases - What if there are no fields? No parameters?