Templates & Metaprogramming

Nostos provides a compile-time metaprogramming system based on templates. This tutorial walks through progressively more complex examples.

Core Concepts

  1. quote { expr } - Capture code as AST data
  2. ~expr - Splice: insert AST values
  3. template 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 name
  • f.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

  1. Start simple - Get basic templates working before adding complexity
  2. Test incrementally - Verify each generated function works
  3. Use meaningful names - fn, typeDef are conventional for decorators
  4. Document what's generated - Comment the expected output
  5. Handle edge cases - What if there are no fields? No parameters?