Cutting Boilerplate Across Targets with Haxe Macros
Haxe macros generate boilerplate at compile time, inlining target-specific code with zero runtime overhead. A worked example shows type-safe JSON serialization across JavaScript, C++, and Python targets, plus practical verification steps and the trade-offs to weigh before adopting macros broadly.
06 Nov 2026, 08:12 UTC

The Problem: Repeating Yourself Across Compilation Targets
You're building a Haxe application that targets JavaScript for the browser, C++ for native performance, and maybe Python for a backend service. Each target needs serialization logic, validation rules, or type-safe accessors for your data models. Writing and maintaining that glue code by hand for every platform is tedious, error-prone, and violates DRY principles. Runtime reflection would work but adds overhead and isn't available on all targets.
Thesis: Compile-Time Generation Keeps It DRY and Fast
Haxe macros execute during compilation and operate on the abstract syntax tree (AST) before target-specific code emission. They let you write generator functions once in Haxe itself, then apply them to classes or fields via metadata. The generated code is inlined into each target's output—JavaScript, C++, C#, Python, etc.—with zero macro runtime overhead. You get type-safe, target-appropriate code without maintaining multiple implementations.
How Macros Fit Into the Build
A macro is a regular Haxe function marked with the :macro metadata that returns an haxe.macro.Expr (an AST node). You invoke it in two main ways:
- Expression macros: called inline like
@:macro myMacro()where an expression is expected. - Build macros: attached to a class with
@:build(MyMacro.build())or@:autoBuild(MyMacro.build())to transform the entire type definition.
The compiler runs your macro code in a special macro context (essentially a Haxe interpreter), giving you access to haxe.macro.Context for type information, position reporting, and error emission. The macro builds an AST fragment that the compiler splices into the compilation unit before target backends see it.
Worked Example: Generating Type-Safe JSON Serialization
Suppose you have a data model used across targets:
// Model.hx
@:build(JsonSerializable.build())
class User {
var id: Int;
var name: String;
var email: String;
public function new(id: Int, name: String, email: String) {
this.id = id;
this.name = name;
this.email = email;
}
}
The build macro inspects the class fields and generates toJson and fromJson methods using the haxe.Json API (available on all major targets). Here's the macro implementation:
// JsonSerializable.hx
import haxe.macro.Expr;
import haxe.macro.Context;
import haxe.macro.Tools;
class JsonSerializable {
public static macro function build(): Array {
var fields = Context.getBuildFields();
var className = Context.getLocalClass();
// Find the constructor to know field order
var constructor = fields.filter(f -> f.kind == FConstructor(f)).single();
var argNames = constructor.args.map(a -> a.name);
// Generate toJson()
var toJsonBody = Expr.ofString('{\n' +
argNames.map(name -> ' "' + name + '": this.' + name).join(',\n') +
'\n}');
var toJson = {
name: 'toJson',
kind: FMethod({
args: [],
ret: null,
expr: toJsonBody,
vars: [],
docs: []
}),
pos: Context.currentPos(),
access: [APublic],
docs: []
};
// Generate static fromJson(json: String): User
var fromJsonBody = Expr.ofString('return haxe.Json.parse(json);');
var fromJson = {
name: 'fromJson',
kind: FMethod({
args: [{name: 'json', type: {name: 'String', params: []}}],
ret: {name: className.get(), params: []},
expr: fromJsonBody,
vars: [],
docs: []
}),
pos: Context.currentPos(),
access: [APublic, AStatic],
docs: []
};
return fields.concat([toJson, fromJson]);
}
}
After compilation, the User class has toJson() and fromJson() methods with no runtime macro dependency. The same macro works when targeting JavaScript (using JSON.stringify under the hood), C++ (using hxcpp's JSON), or Python (using json module).
Verifying the Generated Output
Macro debugging is harder because errors point to generated code. Two practical checks:
- AST inspection: Add
trace(Tools.exprToString(generatedExpr))inside your macro to print the AST as Haxe source during compilation. Runhaxe -main Main -js out.js --macro tracer()from your project root (no special permissions needed) to see the generated code in the build log. - Cross-target compile test: Compile the same project to two targets and diff the relevant output files. For example:
Both commands run from the project root with standard user permissions. Expect the generated method bodies to be semantically equivalent but syntactically adapted to each target.haxe -main Main -js build/js/main.js # then haxe -main Main -cpp build/cpp # compare build/js/main.js and build/cpp/src/Main.cpp for the generated methods
Trade-offs and Limitations
- Build time: Macros run in the compiler process. Complex AST transformations on large codebases can add seconds to minutes per build. Profile with
haxe --timesif builds slow down. - Debugging opacity: Stack traces show generated code locations. Use
Context.makePositionin your macro to map generated nodes back to the original source file for better error messages. - Target constraints: Not all Haxe standard library features exist on every target. A macro generating
sys.io.Filecalls will fail on JavaScript. Guard target-specific generation with#if js/#if cppinside the macro's output expressions. - Team onboarding: Macros are powerful but obscure control flow. Document the macro's contract (what it expects, what it emits) and consider limiting macro use to well-scoped boilerplate like serialization, accessors, or protocol buffers.
Actionable Next Step
Pick one repetitive pattern in your cross-target codebase—JSON serialization, database column mapping, or event dispatcher registration. Write a build macro that generates it for a single class, compile to two targets, and verify the output matches expectations. If the generated code passes your existing tests on both targets, you've eliminated a maintenance surface without adding runtime cost.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.