Using Haxe Macros for Compile-Time Code Generation: A Practical Guide
Haxe macros generate typed code at compile time, eliminating boilerplate with zero runtime cost. This guide walks through a working build-info macro, hygiene rules, compile-time costs, and how to verify expansions in the generated target output.
21 Oct 2025, 00:08 UTC

The problem macros solve
You have a Haxe project that compiles to JavaScript, and you keep writing the same boilerplate: JSON serialization methods, getter/setter pairs, or lookup tables that could be computed from data you already have at build time. Haxe macros let you generate that code during compilation, before the compiler emits target code, so the generated output is typed, checked, and costs nothing at runtime.
The useful takeaway: a macro is an ordinary function marked macro that returns a Haxe expression tree instead of a value. It runs inside the compiler, using the haxe.macro API, and its return value is spliced into the calling code as if you had typed it by hand.
Prerequisites and version assumptions
- Haxe 4.x installed (the examples below assume 4.2+; error reporting for macros improved substantially in the 4.x line, so 3.x output will look different).
- The
haxeCLI on your PATH and an.hxmlbuild file or willingness to pass flags directly. - Basic familiarity with Haxe's expression syntax. You do not need to know the AST types in advance; the
macroreification syntax covers most cases.
A concrete example: build-time constant generation
A common, low-risk first macro is embedding build information — a version string and a timestamp — as compile-time constants. This avoids shipping a runtime file read and works identically on every target.
// BuildInfo.hx
import haxe.macro.Context;
import haxe.macro.Expr;
class BuildInfo {
public static macro function version():ExprOf<String> {
// Runs at compile time, inside the compiler process.
var stamp = Date.now().toString();
return macro $v{stamp}; // $v lifts a compile-time value into an expression
}
}Use it from ordinary code:
// Main.hx
class Main {
static function main() {
trace(BuildInfo.version()); // Expands to a string literal at compile time
}
}Compile with (run in your project directory, no special permissions needed):
haxe -main Main -js main.jsOpen main.js and check that the call site contains a plain string literal rather than a function call. That inspection step is the single most useful verification habit for macro work: always look at the generated target output to confirm the expansion is what you intended.
Reading and transforming expressions
Macros become powerful when they accept expressions and return modified ones. The macro reification syntax (writing Haxe code inside a macro block) keeps this readable, and $e{...}, $v{...}, and $i{...} splice in expressions, values, and identifiers respectively.
// Log.hx
import haxe.macro.Expr;
class Log {
public static macro function info(e:ExprOf<String>):Expr {
// Wrap the argument with a prefix, preserving its position for error reporting.
return macro trace("[info] " + $e{e});
}
}Because the expansion carries position information, a runtime trace in Haxe 4.x still points at the original call site, which makes debugging generated code far less painful than it was in earlier versions.
Hygiene: the decision that bites people
When a macro introduces new identifiers (local variables, fields, or types), you must decide how they interact with the caller's scope. If you generate a variable named temp and the caller already has one, you get a collision or, worse, silent capture of the wrong binding.
The conservative rules:
- Prefer returning self-contained expressions that only reference their parameters and fully qualified names (
Std.string, notstring). - If you must inject a type into the compilation, use
haxe.macro.Context.defineType()with a clearly namespaced pack, rather than generating a type in the caller's module. - Avoid generating bare identifiers that depend on the caller's imports; resolve types explicitly with
Context.getType()orComplexTypeToolswhen you need them.
If you see "unknown identifier" errors only on some targets or only after unrelated refactors, suspect hygiene first.
Compile-time cost and when to stop
Macro execution happens on every compilation, and recursive or heavy AST manipulation can noticeably slow builds on large codebases. Practical guidance:
- Cache expensive results in static fields of the macro class; the compiler process persists for a single build, so a memoized lookup runs once.
- Do not perform network or large file I/O in a macro unless the result is genuinely build configuration. It makes builds non-reproducible and slow.
- If a macro grows past a page of AST manipulation, consider generating a
.hxfile with a separate script instead and checking it in — simpler to review and debug.
Verifying the result
- Compile to your real target (e.g.,
haxe -main Main -js main.js) and inspect the emitted code for the expected literal or structure. - Add a temporary
trace()inside the macro body (not in the returned expression) to observe compile-time values; it prints to the compiler's stderr during the build. - Deliberately break the input to the macro and confirm the compiler reports an error with the correct file and line — Haxe 4.x should point at the call site if you preserved positions.
- Run the compiled output and confirm behavior matches the hand-written equivalent you are replacing.
Limitations and recovery
Macros cannot see runtime values, cannot be stepped through with a normal debugger, and their errors surface as compiler errors, which can confuse teammates unfamiliar with the system. If a macro expansion goes wrong mid-refactor, the recovery is straightforward because macros change no on-disk state: revert the macro file, recompile, and confirm the generated output returns to the previous shape. Keep the hand-written version of at least one call site in a comment or test until the macro has survived a few builds across all your targets.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.