Haxe Conditional Compilation: Why -D debug=0 Still Compiles Debug Code
Haxe's -D flag defines a symbol, not a value, so -D debug=0 still enables #if debug. Here is how to keep conditional compilation small, typed and checkable across targets.
09 Jul 2025, 16:22 UTC

Why -D debug=0 still turns debug code on
Haxe's -D flag defines a symbol. The #if directive then asks whether that symbol exists, not what it equals. So haxe -D debug=0 ... still satisfies #if debug, and the guarded code compiles in. Teams that expect -D to behave like a key/value configuration system usually find this out during a release build.
The practical stance: treat -D as a set of build-time booleans, read values through a macro when you genuinely need them, and keep the conditional surface small — one module per platform concern rather than #if scattered across the codebase.
What the compiler does with #if
Conditional compilation runs before typing. The parser keeps the branches whose conditions are true and discards the rest, so each target is typed from a different abstract syntax tree built from the same source file. That is why a branch may reference an API that does not exist on another target without breaking that target's build: the branch is never typed there.
Predefined symbols come from the target and the compiler — js, php, cpp, python, neko, hl and others, plus capability flags such as sys for targets that ship the sys API, and haxe_ver as a numeric compiler version. Run haxe --help-defines in a terminal with Haxe 4.x installed to list what your compiler version actually defines; if your build does not recognise the option it will say so, and the target's manual is the fallback. Do not assume a flag exists because a library mentions it.
A worked example: one platform module plus a macro for values
Run these from the project root. The only requirement is a Haxe 4.x install on PATH; nothing here writes outside the project directory.
Keep platform differences behind an ordinary function signature so callers never see #if:
// src/Log.hx
class Log {
public static function info(msg:String):Void {
#if sys
Sys.println("[info] " + msg);
#else
trace("[info] " + msg);
#end
}
}
When you need a value rather than a boolean, read it at compile time. Context.definedValue returns the text after the = in -D key=value, or null when the symbol is absent:
// src/BuildInfo.hx
import haxe.macro.Context;
class BuildInfo {
public static macro function apiBase():haxe.macro.Expr {
var v = Context.definedValue("api_base");
if (v == null) Context.fatalError("Missing -D api_base=...", Context.currentPos());
return macro $v{v};
}
}
Context.fatalError aborts the build, so the null branch never reaches the return. Callers receive a compile-time constant, which means the value is inlined and cannot drift at runtime:
// src/Api.hx
class Api {
public static function url():String {
return BuildInfo.apiBase() + "/v1";
}
}
A build file keeps flags next to the target instead of inside a shell script:
# build-js.hxml
-cp src
-main Main
-js out/main.js
-D api_base=https://api.example.test
Build it with haxe build-js.hxml. If api_base is missing, the macro raises a compile error at the call site. That is the point: a missing build input fails the build instead of producing a URL like null/v1.
Trade-offs and where this goes wrong
- Hidden type errors. A branch that only compiles on one target is only type-checked on that target. If the team develops against JS and ships PHP, the PHP branch can rot for weeks. Build every target in CI, even the ones you do not deploy.
- Debugging a moving source line. The same line can produce different code per target, so stack traces need target context. Haxe 4's
-D dump=prettywrites the typed AST into adump/directory in the working directory; branches removed by#ifare absent there, which is a direct way to confirm what survived. - Flag sprawl. Once
#ifappears in twenty files, nobody can say which flag combinations are valid. Prefer one module per platform concern with a target-independent signature, and let the branch live inside it. - Metadata is target-scoped too. Metadata such as
@:exposeonly has meaning on backends that support it; on other targets the compiler does not emit the corresponding export. If a build depends on that export existing, assert it in a target-specific test rather than assuming. Confirm the behaviour against your compiler version's manual before relying on it.
Checks worth running before you ship
- List the symbols your compiler knows:
haxe --help-defines. - Compile the same source for two targets — for example
haxe -cp src -main Main -js out/main.jsandhaxe -cp src -main Main -python out/main.py— then confirm the platform-specific branch appears only in the matching output file. - Type-check without writing artifacts using
--no-output:haxe -cp src -main Main --no-output -D api_base=https://api.example.test. This is the cheap CI step for targets you do not ship. - Grep the source for
#ifand count the files. If the number grows past a handful, that is the signal to consolidate.
Generated output is disposable: rerunning the build overwrites it, so there is nothing to roll back beyond deleting the output directory. What is hard to undo is a conditional branch nobody remembers to test.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.