Haxe abstracts versus typedefs and classes for domain types
Use Haxe abstracts for distinct domain types such as UserId; typedefs only document intent, while classes allocate real objects.
07 Nov 2026, 07:10 UTC

Problem: your cross-target Haxe module needs domain primitives such as UserId, OrderId, and Email. If you model them all as Int or String, the compiler will let you pass an order identifier where a user identifier is expected. If you model them as classes, every target pays for object allocation and identity you do not need.
Takeaway: prefer an abstract over Int or String when you need a distinct compile-time type with no runtime wrapper; use typedef only for documentation, and use a class only when you need inheritance, interfaces, mutable object identity, or runtime type tests.
Decision and constraints
Assumes Haxe 4. An abstract is a compile-time wrapper over an existing type; it is erased in ordinary use, so there is no separate runtime object for simple parameters and fields. A typedef is only a compile-time alias. A class is a real runtime object. Constraints: targets include JavaScript, Java, Python, C++, C#, or HashLink; values of the same underlying type must not be interchangeable at compile time; ordinary use should not allocate; formatting or accessors may be inline; runtime type checks cannot be relied on to distinguish abstracts from their underlying type.
Compare the three options
Use this table to match the language feature to the domain requirement.
| Option | Compile-time identity | Runtime cost | Distinguishable at runtime | Inheritance or polymorphism | Best for |
|---|---|---|---|---|---|
| typedef | No; it is only an alias | None | No | No | Documenting intent when the underlying type is sufficient |
| abstract | Yes; distinct type over an underlying type | No separate object in ordinary use | No; it sees the underlying type | No inheritance | UserId, OrderId, Email, Money across targets |
| class | Yes | Object allocation per instance | Yes | Yes | Subtype polymorphism, interfaces, mutable identity |
Trade-offs
- typedefs document intent but cannot prevent mixing two aliases that share the same underlying type.
- abstracts prevent that mixing at compile time and can expose inline accessors, but they cannot inherit from other abstracts or classes.
- Adding from or to implicit conversions to an abstract makes mixing easier; omit them unless an implicit conversion is truly wanted.
- Classes support object identity and runtime checks, but they allocate and are heavier than necessary for simple identifiers.
- Because abstracts are erased, Std.is or Type.getClass cannot reliably tell a UserId from an Int at runtime.
Concrete implementation: opaque identifiers over Int
This template defines two distinct identifier types over Int. Compile it yourself; the example is not presented as tested output.
package domain;
abstract UserId(Int) {
public inline function new(id:Int) this = id;
public inline function value():Int return this;
}
abstract OrderId(Int) {
public inline function new(id:Int) this = id;
public inline function value():Int return this;
}
package;
import domain.OrderId;
import domain.UserId;
class Main {
static function main() {
var user = new UserId(1001);
var order = new OrderId(2002);
logUser(user);
logOrder(order);
}
static function logUser(id:UserId) {
trace(id.value());
}
static function logOrder(id:OrderId) {
trace(id.value());
}
}
The following file should fail to compile because OrderId is not UserId, even though both wrap Int.
package;
import domain.OrderId;
import domain.UserId;
class BadMix {
static function main() {
var order = new OrderId(7);
acceptUser(order); // expected compile-time type mismatch
}
static function acceptUser(id:UserId) {
trace(id.value());
}
}
Compile and runtime verification
Run from the project root. Required permissions: read and write project files and execute permission for haxe and the target runtime; no elevated permissions are needed. Replace /path/to/project with your actual directory.
cd /path/to/project
haxe -cp src -main Main -js build/js/main.js
haxe -cp src -main Main -java build/java
node build/js/main.js
Expected checks: the good build exits 0 and the JavaScript run exits 0. Repeat the compile for each deployment target, installing the target toolchain when Haxe only generates code. Then compile BadMix and expect a nonzero exit and a type-mismatch diagnostic; do not rely on a specific error message or code across Haxe versions. If BadMix compiles, the boundary is broken, usually because an implicit conversion was added or the wrong type was imported.
Practical result check: inspect build/js/main.js and search for new UserId. For simple parameter and field use, there should be no runtime constructor call; the value is represented as the underlying Int. This is an inspection heuristic, not a proof for Dynamic or untyped contexts, where boxing can still occur.
Limitations and how to check
- Abstracts are erased, so runtime checks cannot distinguish them from their underlying representation.
- Abstracts do not support inheritance; use classes or interfaces when subtype polymorphism is required.
- Target-specific initialization or field-access behavior should be checked on each deployment target before production use.
- Serialization needs explicit converters because the abstract type information is not present at runtime.
No rollback applies because these commands only compile and inspect code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.