Using Haxe Macros to Eliminate Repetitive Data‑Class Boilerplate
Learn how a simple compile‑time macro can generate getters, setters and equality methods for plain data classes, reducing boilerplate while keeping builds fast.
23 Jan 2026, 12:55 UTC

Problem: repetitive boilerplate in data classes
When modeling plain data structures in Haxe you often write the same pattern over and over: private fields, public getters and setters, an equals method and a toString implementation. Writing this manually is error‑prone and makes the source harder to read, especially as the number of fields grows.
Thesis: a compile‑time macro can generate the boilerplate for you
By writing a small macro that runs during compilation, you can inspect the fields of a class and emit the accessor and equality code automatically. The macro runs only once per build, so there is no runtime overhead, and any mistakes are reported as compile‑time errors.
How Haxe macros work (brief)
A macro is a function marked with the macro keyword. It receives an Expr object that represents the code it is applied to. Inside the macro you can pattern‑match on that expression, extract sub‑expressions, and return a new Expr that replaces the original call. Because the macro executes while the compiler is building the program, any syntax or type errors it produces appear in the compiler output.
Worked example: a @:build macro for getters, setters and equality
First, create a macro class that will be attached to a data class via the @:build metadata.
import haxe.macro.Context;
import haxe.macro.Expr;
class DataClassMacro {
public static function build() : Array<Field> {
var fields = Context.getBuildFields();
var newFields = [];
for (f in fields) {
// keep the original field
newFields.push(f);
var name = f.name;
var type = f.type;
// getter
newFields.push({
access: [Access.APublic],
name: "get_" + name,
kind: FieldKind.FMethod(
[],
type,
[
{
access: [],
name: "return",
kind: FieldKind.FExpr(
mkExpr("this.$name")
)
}
]
),
meta: null,
pos: f.pos
});
// setter
newFields.push({
access: [Access.APublic],
name: "set_" + name,
kind: FieldKind.FMethod(
[
{
access: [],
name: "value",
kind: FieldKind.FParam(type, null)
}
],
Type.tVoid,
[
{
access: [],
name: "=",
kind: FieldKind.FExpr(
mkExpr("this.$name = value;")
)
}
]
),
meta: null,
pos: f.pos
});
}
// generate an equals method that compares all fields
var equalsParams = [];
var equalsBody = [];
for (f in fields) {
var name = f.name;
equalsParams.push({access:[], name:"other", kind:FieldKind.FParam(Type.tUnknown, null)});
// we will build the body after the loop
}
// Actually we need a single parameter of the same class type
equalsParams = [ {access:[], name:"other", kind:FieldKind.FParam(Context.getLocalType(), null)} ];
var eqExprs = [];
for (f in fields) {
var name = f.name;
eqExprs.push(mkExpr("this.$name == other.$name"));
}
var equalsBodyExpr = if (eqExprs.length == 0) mkExpr("true") else {
var combined = eqExprs[0];
for (i in 1...eqExprs.length) {
combined = mkExpr("$combined && $eqExprs[i]")
}
combined
};
newFields.push({
access: [Access.APublic],
name: "equals",
kind: FieldKind.FMethod(equalsParams, Type.tBool, [
{access:[], name:"return", kind:FieldKind.FExpr(equalsBodyExpr)}
]),
meta: null,
pos: Context.currentPos()
});
return newFields;
}
// helper to turn a string into an Expr
static function mkExpr(s:String) : Expr {
return Context.parseExpr(s);
}
}
Now annotate any class you want to turn into a data class:
@:build(DataClassMacro.build())
class Point {
public var x:Float;
public var y:Float;
public function new(x:Float, y:Float) {
this.x = x;
this.y = y;
}
}
When you compile the project, the macro runs and produces the following members inside Point (visible in the generated JavaScript or other target):
get_x()andset_x(v:Float)get_y()andset_y(v:Float)equals(other:Point):Bool
You can then use the class as usual:
var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
trace(p1.equals(p2)); // prints true
Trade‑offs and limitations
- Compile‑time cost: The macro walks the AST of every annotated class. In large code bases this can add a noticeable fraction to build time. Keep the macro logic simple and avoid expensive operations such as file I/O.
- Error messages: If the macro returns an invalid
Expr, the compiler reports the error at the macro call site, which can be cryptic. Adding explicitContext.error(...)checks for unsupported field types (e.g., functions) helps produce clearer messages. - Version sensitivity: The macro uses the
haxe.macro.ContextAPI, which is stable across recent Haxe releases, but a major language change (e.g., a new field kind) could require adjustments. Test the macro when upgrading Haxe.
Practical verification steps
- Create a file
src/Test.hxcontaining the macro class and a test class annotated with@:build(DataClassMacro.build()). - Run the compiler from a terminal with appropriate access to your source directory (no special permissions needed):
haxe -main Test -js out.js - Inspect the generated
out.js(or the equivalent output for your target). You should see function definitions corresponding to the getters, setters andequalsmethod. - As a safety check, introduce a deliberate mistake inside the macro—for example, return
mkExpr("invalid syntax")—and recompile. The compiler should emit an error at this step, not at runtime.
If the generated methods appear in the output and the compiler catches malformed macro returns, the macro is working as intended.
Actionable closing
Start small: write a macro that handles only simple fields (ints, floats, strings) and gradually add support for more complex types as you need them. Keep the macro’s side‑effects limited to code generation, and always verify the output after each change. This approach gives you the convenience of boiler‑plate‑free data classes while preserving the predictability and speed of Haxe’s compile‑time pipeline.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.