Using Haxe Macros as a Compile-Time Enforcement Layer: An Architecture Note
An architecture note on using Haxe compile-time macros as the smallest design for rules that must never be bypassed at runtime — covering trust boundaries, operational checks, and when to replace macros with external generators.
19 Oct 2025, 15:15 UTC

Some rules in a codebase should be impossible to break at runtime: SQL fragments must come from a fixed allowlist, the route table must match the handler signatures, DTO field mappings must agree with the wire schema. You can enforce these with runtime checks and code review, or you can make violations unrepresentable. Haxe macros — code that runs inside the compiler and can inspect and generate typed syntax trees — are often the smallest design that achieves the second option, because they remove an entire class of runtime checks rather than adding another one.
This note covers when that trade is worth making, the smallest design that works, where the trust boundaries sit, and the failure modes that should make you reconsider.
Requirements that justify a macro
A macro earns its place when all of the following hold:
- The rule must hold for every call site, including ones written next year by someone who has not read the design doc.
- All inputs needed to enforce the rule are known at compile time (field names, route strings, enum cases).
- The runtime alternative would be a validation pass plus a test suite that tries to cover every call site — and still misses some.
If any input is only known at runtime, or the rule is advisory rather than absolute, ordinary typed functions are the better tool. Macros are a poor substitute for normal abstraction.
The smallest suitable design: thin macros, thick runtime code
The pattern that ages well is: the macro transforms declared intent into generated code, then hands off to ordinary runtime functions. Business logic stays in normal code, where it can be unit-tested without invoking the compiler.
A concrete example: a macro that builds a typed field accessor from a string literal, rejecting unknown fields at compile time instead of returning null at runtime.
import haxe.macro.Context;
import haxe.macro.Expr;
class FieldAccess {
public static macro function get<T>(obj:ExprOf<T>, field:ExprOf<String>):Expr {
switch (field.expr) {
case EConst(CString(name)):
var fields = switch (Context.follow(Context.typeof(obj))) {
case TInst(_, _) => Context.typeof(obj);
case _ => Context.error("Expected a class instance", obj.pos);
};
var known = [for (f in Context.getType(Type.toString(Context.typeof(obj))) == null ? [] : Type.getClassFields(Type.resolveClass(Type.toString(Context.typeof(obj))) == null ? "" : "")) f];
// Simpler: check against the type's fields via Context
var t = Context.follow(Context.typeof(obj));
var names = switch (t) {
case TInst(c, _): [for (f in c.get().fields.get()) f.name];
case _: [];
};
if (names.indexOf(name) == -1)
Context.error('Unknown field "$name"; known: ${names.join(", ")}', field.pos);
return macro $obj.$name;
case _:
return Context.error("Field name must be a string literal", field.pos);
}
}
}
The important properties, not the exact API calls (which differ between Haxe 3 and Haxe 4 — check haxe.macro.Context against your compiler version):
- The macro only accepts a string literal, so no runtime-computed value can bypass the check.
- Errors are reported with
Context.error(msg, pos)using the call site's position, not a position inside the macro. This is the single biggest usability factor; without it, developers see errors pointing into macro internals and learn to distrust the tool. - The generated expression is trivial — a plain field access — so the runtime artifact contains no macro machinery at all.
Trust and data boundaries
Macro code executes inside the compiler process. Depending on compilation flags and target, it can access the filesystem and network via the sys APIs. That creates two boundaries worth stating explicitly:
- Build configuration → macro: trusted. Compiler flags, defines, and annotated source are inputs you control.
- External data → macro: untrusted until validated. Never feed unvalidated external content into
Context.parseor splice it into generated expressions. A macro that reads a JSON schema file to generate types should validate the file's shape before generating anything, and the file should be a pinned, versioned input — not something fetched at build time.
A macro that reads files or shells out makes your build non-hermetic: the same source can compile to different output on different machines. If you must do it, pin the input (commit it, hash it) and log it in CI so the dependency is visible.
Operational checks
Macros shift cost from runtime to compile time, so measure that cost and verify determinism:
- Inspect generated output. Compile with
-D dump=prettyto write the typed AST of each module to adump/directory. Confirm the generated code is what you intended — especially that the macro is not silently generating more than you think. - Watch macro execution time. Compile with
-D macro-timesto get per-macro timing. A macro that adds seconds to every build will be worked around, and worked-around enforcement is no enforcement. - Test clean builds in CI. The Haxe compilation server caches macro results across compilations. A macro with hidden nondeterminism (reading a file, depending on call order) can produce stale output that only a clean build reveals. Run CI with the server disabled or freshly started, and if you suspect staleness, build twice with the server running and diff the artifacts.
- Verify on every target you ship. Generated code that works on the JS target can behave differently on C++ or HashLink, particularly around reflection and dynamic field access. Compile to each target and run a smoke test against the artifact.
- Check error positions deliberately. Pass invalid input to the macro in a scratch file and confirm the compiler error points at the call site. This takes five minutes and saves every future user of the macro an hour.
Failure modes
- Confusing diagnostics. The most common outcome of a poorly positioned
Context.erroris that developers bypass the macro entirely. Treat error quality as a feature. - Stale compilation-server caches. Recursive or context-dependent macros can cache results keyed on incomplete state. Symptom: output that only updates after a server restart. Fix: make macros pure functions of their arguments and declared inputs, and keep clean-build CI as the backstop.
- Compile-time blowup. Macros that re-run expensive work per call site (re-reading a schema, re-following the same types) scale linearly with usage. Cache within a single compilation via static state, carefully.
- Upgrade coupling. Heavy AST manipulation couples you to compiler internals. The
haxe.macroAPI is stable in broad strokes but version-sensitive in detail; a codebase with thousands of lines of macro code pays a real tax on every Haxe upgrade.
Conditions that should change the design
Revisit the macro approach when:
- Inputs become runtime-known. If the schema or route table moves to a config service, compile-time enforcement is no longer possible; move to runtime validation with a generated, versioned artifact.
- Generation logic outgrows a few hundred lines. At that point an external code generator (run in CI, output committed or produced as a build step) is easier to test, debug, and share.
- Non-Haxe tooling needs the same logic. A macro is invisible to anything that is not the Haxe compiler. If your TypeScript frontend or your documentation pipeline needs the same route table, generate from a shared spec instead.
- Compile times become the bottleneck. If
-D macro-timesshows macros dominating the build, the enforcement is costing more than the class of bugs it prevents.
The design goal is not "use macros" — it is "make the rule unbreakable with the least machinery." A thin macro over ordinary runtime code is usually that minimum. A thick macro that contains your business logic is a compiler plugin you now have to maintain.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.