Nim Metaprogramming: Templates, Macros, Generics, and CTFE
Verified reference for Nim's compile-time metaprogramming system: hygienic templates and their call-by-name semantics, AST-level macros with NimNode and quote do:, generic constraints and the current stability status of concepts, compile-time function execution limits, and the when-vs-if distinction that trips up code generation.
Introduction / Overview
Nim's compile-time metaprogramming system - template, macro, generics, concepts, and compile-time function execution (CTFE) - is one of the language's most powerful features and, simultaneously, one of the areas where generated code is most likely to be confidently wrong. The core source of confusion is a category error that is easy for a language model to make: code written inside a macro body executes at compile time, manipulating an abstract syntax tree, to produce code that will run later, at runtime. Conflating "what the macro body does" with "what the code it generates does" is the single most common structural mistake in generated Nim metaprogramming.
A second, equally load-bearing distinction is that Nim templates do not evaluate their arguments eagerly before substitution - an argument expression is spliced, verbatim, into every place its parameter name appears in the template body, and is evaluated exactly as many times as it is referenced. This is closer to a C preprocessor macro's substitution model than to any language's normal function-call semantics, and it produces a well-documented, easy-to-miss multiple-evaluation bug class covered in this document's pitfalls section.
This document assumes the ecosystem and best-practice context in README.md and bestpractices.md, and the general pitfall catalogue in commonpitfalls.md; it focuses exclusively on the compile-time metaprogramming layer.
When to use this guidance: You are writing, generating, or reviewing Nim templates, macros, generic procs/types, concepts, or any code relying on when/static/{.compileTime.} compile-time evaluation.
Core Concepts
template: hygienic, compile-time syntactic substitution - the template body is spliced into the call site before type-checking, with no separate execution phase of its own.macro: full AST-level transformation - the macro body is an ordinary (restricted) Nim proc that runs at compile time, receiving and/or returningNimNodevalues, and its return value is the AST that gets compiled and run later.- Generics (
[T]): compile-time type parameterization, resolved and instantiated once per concrete type used, optionally constrained by a type class (SomeInteger) or aconcept. - CTFE (Compile-Time Function Execution): the compiler's ability to execute a restricted subset of ordinary Nim code (not just macros) during compilation, powering
constinitializers andstatic[T]arguments. when: a compile-time conditional - its untaken branch is not even fully semantically checked the way anif's untaken runtime branch is; it looks likeifand is trivially confused with it.- Hygiene: the mechanism (automatic symbol renaming,
gensymby default,{.inject.}to opt out) that prevents a template's or macro's internal identifiers from accidentally capturing or clashing with identifiers at the call site.
Best Practices
1. template - Hygienic Substitution, Not a Function Call
Rationale: A template is expanded at its call site before type-checking, with its parameters substituted as expressions (or, for untyped parameters, as raw, unanalyzed AST) rather than evaluated once and bound like an ordinary function argument. This makes templates ideal for zero-overhead inlining, control-flow-shaped DSL sugar (a custom unless, a scoped resource-cleanup block), and injecting boilerplate - but it means a template does not behave like a function with respect to argument evaluation.
template unless(cond: bool, body: untyped): untyped =
if not cond:
body
let n = 5
unless(n > 10):
echo "n is not greater than 10"
untyped parameters (as body above) receive the argument as an unanalyzed syntax tree - no type-checking is performed on them before substitution, which is exactly what allows a bare code block (not a valid standalone expression) to be passed as body. A parameter declared with a concrete type instead (e.g., cond: bool above) is type-checked at the call site before substitution, but - critically - it is still substituted as an expression, not evaluated once into a temporary, and can therefore still be evaluated multiple times if the template body references it more than once (see the Common Pitfalls section).
Why this works well in production: template gives you macro-free, zero-runtime-overhead code reuse for control-flow-shaped patterns that a plain proc cannot express (you cannot pass "a block of statements" to an ordinary proc parameter), while remaining far easier to read, write, and debug than a full AST-manipulating macro.
2. macro - Compile-Time AST Transformation
Rationale: A macro receives its arguments as NimNode AST values (for untyped/typed parameters) and must return a NimNode - the AST that will actually be compiled and executed at runtime. The macro body itself runs once, at compile time, for each call site; it is not present in, and has no direct relationship to, the runtime behavior of the program beyond the AST it emits.
import std/macros
macro double(x: untyped): untyped =
result = quote do:
`x` + `x`
let a = 5
echo double(a) # expands, at compile time, to `echo a + a` -> prints 10
quote do: is Nim's quasi-quotation construct: it takes an ordinary block of concrete Nim syntax and returns the NimNode AST that represents it, letting you build AST by writing normal-looking Nim code instead of manually constructing newTree/newCall/newIdentNode calls. Backtick-prefixed identifiers inside a quote do: block (` x above) are interpolation points - they splice in an existing NimNode value (here, the macro's own x parameter) rather than being treated as a literal identifier named x`.
The same macro can be built without quote do:, directly manipulating the AST - more verbose, but useful when the shape of the generated code is itself dynamic and doesn't fit a single fixed quote do: template:
import std/macros
macro genRangeEcho(a, b: static[int]): untyped =
result = newStmtList()
for i in a..b:
result.add newCall(ident("echo"), newIntLitNode(i))
genRangeEcho(1, 3) # expands, at compile time, into three separate `echo` calls
The classic confusion this best practice exists to prevent: the for i in a..b loop above executes at compile time, once, to build the AST - it is not a runtime loop, and it does not appear as a loop in the compiled output at all; the compiled output contains three separate, already-unrolled echo calls. Code inside a macro's own body (ordinary if/for/var used to build up result) runs during compilation to decide what code to generate; it is a categorically different thing from the runtime behavior of the AST that macro eventually returns.
Why this works well in production: macros are the only mechanism that can inspect and transform the actual syntax tree of their arguments (branch on whether an argument is a literal versus a call, rewrite a whole statement list, implement a DSL with custom parsing-adjacent behavior) - capabilities no amount of template substitution or generic instantiation can reach.
3. Generics and Constraints
Rationale: [T] introduces an unconstrained type parameter; [T: SomeInteger] (or any user type-class/concept) constrains it. An unconstrained T's body is checked once per instantiation against whatever operations are actually used - the compiler does not require every possible operation on T to be pre-declared the way a Rust trait bound or a C++ concept requires.
proc largest[T: SomeNumber](a, b: T): T =
if a > b: a else: b
echo largest(3, 7) # T = int
echo largest(3.5, 2.1) # T = float
concepts provide structural, duck-typed constraints - a type matches a concept if the expressions in the concept's body compile for that type, with no explicit impl-style declaration required:
type Comparable = concept x, y
(x < y) is bool
proc largest[T: Comparable](a, b: T): T =
if a < b: b else: a
Verified current status: concepts are documented in manualexperimental.html rather than the stable manual.html, and open compiler issues report overly permissive matching and occasional crashes on nontrivial concept bodies. Treat concepts as usable but genuinely less mature than ordinary generic constraints - verify behavior against the current compiler version before depending on a nontrivial concept in production code, and prefer a plain SomeX-style type-class bound wherever it suffices. See commonpitfalls.md for worked examples of concepts being mismatched against Rust-trait intuitions.
4. Compile-Time Function Execution (CTFE)
Rationale: Nim can execute ordinary procs - not just macro bodies - during compilation, whenever a value is required in a compile-time context: a const initializer, a static[T] argument, or an expression a macro needs evaluated. The {.compileTime.} pragma marks a proc as intended specifically for compile-time use (typically helper procs called from within macros), and such a proc's variables persist across separate compile-time invocations within the same compilation.
proc factorial(n: int): int {.compileTime.} =
result = 1
for i in 2..n:
result *= i
const Fact10 = factorial(10) # computed entirely at compile time, baked into the binary
Verified limitations: the manual explicitly prohibits several constructs inside compile-time-executed code: methods, closure iterators, the cast operator, reference (ref/ptr) types, and FFI - including standard library wrappers that themselves rely on FFI or cast internally. The manual notes these restrictions "are likely to be lifted over time," but as of the current stable compiler line they remain hard limits - a const initializer or static[T] argument that transitively calls into networking, threading, or most FFI-backed stdlib code will fail to compile, not silently fall back to runtime evaluation.
# Wrong: assuming any proc can run at compile time, including one using `cast`/FFI internally
proc readMagicByte(): byte {.compileTime.} =
var p: ptr byte = cast[ptr byte](someAddress) # compile error: `cast` is disallowed in CTFE
p[]
const MagicByte = readMagicByte()
# Correct: CTFE-safe code sticks to pure computation over ordinary value types
proc computeChecksum(data: openArray[byte]): int {.compileTime.} =
for b in data:
result += int(b)
const Checksum = computeChecksum([1'u8, 2, 3])
5. when vs if - Compile-Time Branching, Not Runtime Branching
Rationale: when reads almost identically to if, but its condition is evaluated at compile time and its untaken branch is not semantically checked and compiled the way an if's untaken runtime branch is - the untaken branch can reference symbols, types, or platform-specific code that would not even compile on the current target, and this is not an error, because that branch is discarded before full semantic analysis.
when defined(windows):
proc platformPath(): string = r"C:\data"
else:
proc platformPath(): string = "/data"
Both branches above define a proc with the same name, which would be an ordinary redeclaration error under if - it is legal under when precisely because only one branch is ever compiled at all. A common, verified idiom pairs when with the special nimvm symbol to select between a CTFE-safe code path and an ordinary runtime path within the same routine:
proc getValue(): int =
when nimvm:
100 # taken when this proc is evaluated at compile time (CTFE)
else:
computeAtRuntime() # taken during ordinary runtime execution
The confusion this exists to prevent: substituting if for when (or vice versa) when translating between "this should be resolved once, at compile time" and "this should be checked every time the program runs" changes both correctness and compileability. An if defined(windows): ... (using runtime if instead of when) still compiles both branches unconditionally on every platform - code referencing a Windows-only symbol inside a plain if defined(windows): will fail to compile on Linux, exactly the failure when exists to avoid.
6. Macro Hygiene: gensym and {.inject.}
Rationale: By default, a symbol declared inside a template or macro's generated code is renamed (gensymed) to a fresh, call-site-invisible name, preventing the template/macro's internal implementation details from accidentally capturing or clashing with an identifier the caller happens to also use. This is what makes it safe to write let temp = ... inside a template body without fear of colliding with a caller's own temp variable. When the caller genuinely needs to see and use a name the template introduces - the defining use case for resource-cleanup and scoped-block templates - that specific symbol must be explicitly marked {.inject.} to opt out of the default hiding.
template withFile(path: string, mode: FileMode, body: untyped): untyped =
let f {.inject.} = open(path, mode) # {.inject.}: deliberately visible to `body`
try:
body
finally:
close(f)
withFile("out.txt", fmWrite):
f.writeLine("hello") # `f` resolves here only because of the {.inject.} above
Without {.inject.}, f inside the template would be renamed to a hygienic, caller-invisible symbol, and the body block's reference to f would fail to resolve - a compile error that looks like a missing variable, not a hygiene issue, until this mechanism is understood.
7. Practical Patterns
Resource-cleanup template (the idiomatic Nim equivalent of a Python context manager or a Rust RAII guard, built from Best Practice 1 and 6 together): shown above as withFile. The same shape generalizes to any acquire/use/release pattern - a lock, a database transaction, a timer:
template withLock(l: var Lock, body: untyped): untyped =
acquire(l)
try:
body
finally:
release(l)
var counterLock: Lock
initLock(counterLock)
withLock(counterLock):
inc sharedCounter
Small DSL macro (from Best Practice 2's quote do: pattern, extended to a slightly more realistic case - a macro that logs an expression's source text alongside its runtime value, a common "debug print" DSL that a plain template cannot express because it needs the argument's unevaluated source text, not just its value):
import std/macros
macro dump(x: untyped): untyped =
let label = x.toStrLit
result = quote do:
echo `label`, " = ", `x`
let total = 42
dump(total) # expands, at compile time, to: echo "total", " = ", total
x.toStrLit converts the macro parameter's AST back into a string literal NimNode representing its source text ("total"), which a template's simple substitution has no mechanism to produce - this is a genuine macro-only capability, and a correct illustration of when reaching for macro over template is actually necessary.
Common Pitfalls & Anti-Patterns
Pitfall: Assuming a Template Parameter Is Evaluated Once
Problem: because template arguments are substituted as expressions rather than evaluated once into a bound temporary, a parameter referenced more than once in the template body causes the caller's expression to be evaluated once per reference - including any side effects it carries. This is the direct Nim analogue of the classic C preprocessor macro multiple-evaluation bug, and it affects both untyped and ordinarily typed template parameters equally, since the substitution mechanism itself does not distinguish them.
# Wrong: `a` is referenced twice in the body, so a side-effecting argument runs twice
var calls = 0
proc nextId(): int =
inc calls
calls
template maxOf(a, b: untyped): untyped =
(if a > b: a else: b)
echo maxOf(nextId(), 10) # `nextId()` may be evaluated once for the comparison and
# again for whichever branch is taken - `calls` ends up
# incremented more than once, and the returned value can be
# inconsistent with a single logical call to `nextId()`
Recommended approach: bind each parameter to a local let at the very top of the template body when it is referenced more than once, forcing single evaluation before any branching or repeated use:
template maxOf(a, b: untyped): untyped =
let x = a
let y = b
(if x > y: x else: y)
echo maxOf(nextId(), 10) # `nextId()` is now evaluated exactly once
Pitfall: Forgetting {.inject.}, Then Concluding the Template Is Broken
Problem: a template that declares a variable meant to be visible inside a caller-supplied body block, without marking it {.inject.}, produces a confusing "undeclared identifier" error at the call site, inside code the caller wrote - which reads like a caller mistake, not a hygiene omission in the template's own definition.
Recommended approach: any identifier a template's caller is expected to reference from within a passed body (or from after the template call, if the template is meant to introduce a call-site-visible binding) must be explicitly marked {.inject.}, as shown in Best Practice 6. When reviewing a template that mysteriously fails to resolve an identifier at its call site, check the template's own declarations for a missing {.inject.} before assuming the caller's code is wrong.
Pitfall: Treating a Macro's Own Body as Runtime Code
Problem: generated code sometimes treats loops, conditionals, or I/O written directly inside a macro body as if they execute when the generated code runs, rather than once, at compile time, while the macro is building its result AST. This leads to macros that echo debug output at the wrong time (during compilation, flooding build logs, not during program execution) or that attempt runtime-only operations (genuine file/network I/O against the running program's environment) inside macro logic that Nim's CTFE restrictions do not actually permit in the first place.
Recommended approach: remember that everything in a macro's own body - not spliced into a quote do: block or built as a NimNode and added to result - runs at compile time, once per call site, as part of deciding what code to generate; only the AST ultimately returned in result becomes the program's actual runtime behavior (Best Practice 2).
Pitfall: Reaching for concept Where a Simple Type-Class Bound Would Do
Problem: pattern-matching from Rust trait bounds or C++20 concepts, generated code reaches for a Nim concept by default for constraints that a plain [T: SomeInteger]-style bound already expresses correctly and more stably.
Recommended approach: default to built-in type classes (SomeInteger, SomeFloat, SomeNumber, SomeOrdinal) or a simple custom type class for common numeric/ordinal constraints; reserve concepts for genuinely structural, duck-typed requirements that cannot be expressed any other way, and verify nontrivial concept bodies against the current compiler before shipping them (Best Practice 3).
Pitfall: Confusing when and if for Platform or Feature Branching
Problem: using runtime if defined(...) instead of compile-time when defined(...) forces the compiler to fully check both branches on every platform, causing a build failure on any platform where the untaken branch references a symbol that doesn't exist there - a failure mode that only manifests when someone actually builds on the "other" platform, often long after the code was written and merged.
Recommended approach: any branch selecting between platform-specific code, feature flags (defined(ssl), defined(release)), or CTFE-vs-runtime code paths (nimvm) must use when, not if (Best Practice 5).
Testing Strategies
expandMacros: wrap a call site in theexpandMacros:block (or use the--expandMacrocompiler switch) to print the fully expanded code a macro or template produces, making multiple-evaluation and hygiene bugs directly visible rather than inferred from confusing downstream errors.- Unit-test the generated behavior, not the macro's internals: treat a
macro/templateas a black box from the test's perspective - assert on the runtime behavior of code that uses it, exactly as you would for a hand-written equivalent, since the AST it produces is an implementation detail. - Dedicated multiple-evaluation regression tests: for any template whose parameter is referenced more than once in its body, add a test that passes an argument expression with an observable, countable side effect (as in this document's
maxOf/nextIdexample) and assert the side effect fired exactly once. - CTFE-path tests: for any
{.compileTime.}proc orstatic[T]-parameterized routine, add aconstbinding that forces compile-time evaluation, so a CTFE-limitation regression (Best Practice 4) is caught at compile time, not discovered at first use. - See
testing.mdfor the fullstd/unittest/testamentsetup this pairs with.
Performance, Security & Scalability Considerations
- Templates have zero runtime call overhead (true inlining, since the body is spliced in before compilation) - prefer them over an equivalent
procon hot paths where the call overhead genuinely matters, but only once correctness (particularly the multiple-evaluation pitfall above) is verified. - Macros can generate code bloat if used to unroll large, data-driven loops at compile time (as in the
genRangeEchoexample) - weigh compile-time code generation against binary size and compile-time cost for largea..branges or similarly data-scaled expansions. - CTFE moves computation from runtime to compile time, which is a strict security and performance win for genuinely constant inputs (precomputed lookup tables, checksums of embedded data) but is not a substitute for runtime validation of anything that is actually only known at runtime - do not attempt to force user- or environment-supplied values through a
{.compileTime.}proc. - Concept-based generic constraints (Best Practice 3) carry the same maturity caveat under load as anywhere else - a concept that compiles today is not guaranteed to compile identically after a compiler upgrade, given its documented, verified instability; pin the Nim compiler version for any codebase depending on nontrivial concepts.
Edge Cases & Advanced Usage
bindSym: inside a macro,bindSym("someIdent")resolves an identifier from the macro's own definition scope at macro-expansion time, rather than the call site's scope - necessary when a macro needs to reference one of its own helper symbols regardless of what identifiers happen to be in scope at each call site.getAst: obtains the AST atemplatewould expand to, without actually expanding/splicing it inline - useful inside amacrothat wants to programmatically combine a template's expansion with additional generated code.- Recursive macros: a macro can call itself (or another macro) during its own compile-time execution to build up AST recursively (e.g., unrolling a nested data structure) - but each recursive call still executes fully at compile time, and unbounded recursion here is a compile-time infinite loop, not a runtime one, and will hang or exhaust the compiler rather than the running program.
- Typed vs untyped macro/template parameters at scale: a
typedparameter forces full semantic analysis (type-checking, overload resolution) of the caller's expression before the macro/template ever sees it, which is required if the macro body needs to inspect the resolved type - but it also means the caller's code must already be valid, standalone, type-checkable Nim, unlikeuntyped, which accepts fragments (like a bare code block) that would not type-check in isolation.
When Not to Use / Alternatives
| Mechanism | Best for | Avoid when |
|---|---|---|
template | Zero-overhead inlining, control-flow-shaped sugar, simple boilerplate elimination | The body needs to inspect or transform the shape of its arguments (branch on whether something is a literal, a call, an identifier) |
macro | Genuine AST transformation, source-text-aware DSLs, code generation from a compact spec | A template or a plain generic proc already expresses the same behavior - a macro's added power costs debuggability and readability |
Generic proc with a type-class bound | The overwhelming majority of "work over several types" needs | The constraint is genuinely structural/duck-typed and no built-in type class fits |
concept | Genuinely structural constraints with no other expression | Stability matters more than expressiveness for the constraint at hand (Pitfall above) - prefer a type-class bound |
CTFE / {.compileTime.} | Precomputing genuinely constant data (lookup tables, checksums of embedded literals) at zero runtime cost | The input is only known at runtime - this is not a mechanism for deferring runtime validation |
Choose a plain generic proc over both template and macro by default - reach for template only once a proc genuinely cannot express the needed control-flow shape, and for macro only once a template genuinely cannot express the needed AST-level transformation. See bestpractices.md for the full six-way comparison across proc/func/template/macro/iterator/method.
Related Documentation
- Nim Language Overview & Ecosystem
- Nim Best Practices
- Nim Common Pitfalls & Anti-Patterns
- Nim Concurrency
- Nim Testing
- Nim FFI/Interop
- Clean Code Principles
Glossary
NimNode: The compiler's AST node type; the value macros receive as arguments and must return as their result.quote do:: A quasi-quotation construct that converts a block of concrete Nim syntax into theNimNodeAST representing it, with backtick-prefixed identifiers marking interpolation points.- Hygiene: The default renaming (
gensym) of symbols a template/macro introduces, preventing accidental capture of or clashes with call-site identifiers. {.inject.}: A pragma that opts a template/macro-introduced symbol out of default hygiene, making it visible at the call site.- CTFE: Compile-Time Function Execution - the compiler's ability to run a restricted subset of ordinary Nim procs during compilation.
static[T]: A generic parameter kind requiring its argument to be known at compile time, enablingwhen-based branching on its value inside the routine body.concept: Nim's structural/duck-typed generic constraint mechanism, documented in the experimental manual and less mature than ordinary generic constraints.when: A compile-time conditional whose untaken branch is not fully semantically checked, distinct from the runtimeif.
Maintenance note: When updating this document, also update lastupdated and re-verify the concept-stability status, CTFE restriction list, and quote do:/macro syntax against the current Nim manual and manualexperimental.html.