Automating JSON Serialization in Haxe with @:genericBuild Macros
Learn how Haxe’s @:genericBuild macro can generate toJson/fromJson methods for any struct, removing boilerplate and runtime reflection while staying cross‑target.
14 Sept 2025, 20:13 UTC

Problem: boilerplate serialization code
When you define a data structure in Haxe, you often need to write matching toJson and fromJson methods for each target. Doing this by hand repeats the same field‑by‑field logic, makes refactoring error‑prone, and forces you to maintain parallel implementations for JavaScript, C++, Python, etc.
Thesis: compile‑time macros can generate zero‑cost serialization once
Haxe’s @:genericBuild macro runs during the type‑checking phase and receives the full abstract syntax tree of the annotated type. By implementing a builder that walks the fields and emits toJson and fromJson functions, you get serialization code that is inlined by the compiler, incurs no runtime reflection cost, and works uniformly across all supported targets.
Macro basics
The @:genericBuild attribute expects a static function with the signature function build(c:ClassBuilder):ClassBuilder. The ClassBuilder provides methods such as getFields() (returns an array of Field objects) and addMethod() to inject new functions. The macro runs after the class fields are known but before code generation, so the generated methods become part of the class definition for every target.
Worked example: a JSON serializer macro
First, define the macro in a file MyJsonSerializer.hx:
class MyJsonSerializer {
public static function build(c:ClassBuilder):ClassBuilder {
var fields = c.getFields();
// toJson
var toJsonBody = new StringBuilder();
toJsonBody.append('var obj = {};\n');
for (f in fields) {
toJsonBody.append('obj.' + f.name + ' = this.' + f.name + ';\n');
}
toJsonBody.append('return JSON.stringify(obj);');
c.addMethod({
name: 'toJson',
ret: Type.tString,
params: [],
expr: macro $v{toJsonBody.toString()},
access: [Access.APublic]
});
// fromJson
var fromJsonBody = new StringBuilder();
fromJsonBody.append('var data = JSON.parse(json);\n');
for (f in fields) {
fromJsonBody.append('this.' + f.name + ' = data.' + f.name + ';\n');
}
c.addMethod({
name: 'fromJson',
ret: Type.tvoid,
params: [{name: 'json', ret: Type.tString}],
expr: macro $v{fromJsonBody.toString()},
access: [Access.APublic]
});
return c;
}
}
Next, annotate a struct with the macro:
@:build(MyJsonSerializer.build)
struct User {
var name:String;
var age:Int;
var active:Bool;
}
When you compile, the macro injects the following methods (conceptually; you can inspect them with -D dump):
function toJson():String {
var obj = {};
obj.name = this.name;
obj.age = this.age;
obj.active = this.active;
return JSON.stringify(obj);
}
function fromJson(json:String):Void {
var data = JSON.parse(json);
this.name = data.name;
this.age = data.age;
this.active = data.active;
}
You can now use the struct without writing any serialization logic:
class Main {
static function main() {
var u = new User();
u.name = 'Ada';
u.age = 30;
u.active = true;
var json = u.toJson(); // {"name":"Ada","age":30,"active":true}
var u2 = new User();
u2.fromJson(json);
trace(u2.name); // Ada
}
}
Trade‑offs
- Compile time: The macro walks every annotated type, which can add a few seconds to large projects.
- Debugging: Macro errors appear as cryptic compile‑time messages; using
haxe --macro 'trace(c.getFields())'or compiling with-D no-inlininglets you view the generated code. - Benefit: No runtime reflection, smaller output (especially on C++ where RTTI can be stripped), and identical behaviour across JavaScript, C++, Python, etc.
Closing: adding the macro to your workflow
- Place
MyJsonSerializer.hxin a folder that is on your classpath (e.g.,src/). - Add the macro to your
build.hxml:-cp src -main Main -js out.js - Compile with
haxe build.hxml. - Verify the generated JavaScript with Node:
node out.jsshould print the expected trace. - Optionally, dump the intermediate Haxe to see the injected methods:
haxe -D dump build.hxml.
Limitations: the macro as shown only handles simple fields (no nested objects, enums, or abstracts). Extending it to support those cases requires recursive field inspection and type‑specific handling, but the same pattern applies.
By moving serialization logic to compile time, you eliminate repetitive boilerplate, gain zero‑cost runtime performance, and keep a single source of truth for your data models across all Haxe targets.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.