Haxe Conditional Compilation: One Codebase, Clean Output Per Target
Use Haxe's #if directives to keep platform-specific code out of the wrong target's build — with a worked Storage example and the pitfalls to watch.
03 Mar 2026, 00:12 UTC

The Cross-Platform Logic Gap
Haxe compiles one codebase to many targets — JavaScript for the browser, C++ or HashLink for native desktop, plus Lua, Python, and others. Sooner or later a single API cannot cover them all. A game might need to save a high score to localStorage in the browser, but write to a file on disk when running natively. A naive wrapper that calls both APIs fails, because the browser has no file system and a native app has no localStorage. A runtime check like if (isBrowser) does not help either: the native-only code still has to compile and still ships in every build.
The fix is conditional compilation. Rather than runtime checks, conditional compilation tells the Haxe compiler to include or omit code blocks per target before any output is produced.
How #if Directives Work
Haxe evaluates #if, #elseif, #else, and #end at compile time. If you target JavaScript, anything wrapped in #if cpp is stripped from the output entirely. That keeps scripts small and, more importantly, stops the compiler from pulling native-only APIs into an environment where they cannot exist. Directives nest, and conditions support boolean operators such as &&, ||, and !.
Custom Build Flags
Alongside the flags the compiler defines for each target (such as js, cpp, and hl), you can define your own to toggle feature sets such as verbose diagnostics. Pass them with the -D option on the command line or in a .hxml build file (lines starting with # are comments there):
# build.hxml
-D verbose_logging
-main Main
-js main.js
Then gate code with #if verbose_logging. Compiling with -debug also defines the debug flag automatically in Haxe 4.x, so you rarely need to set that one by hand.
Worked Example: A Platform-Aware Saver
This Storage class assumes Haxe 4.x and switches between browser and native persistence behind one identical method signature:
class Storage {
public static function save(key:String, value:String):Void {
#if js
js.Browser.window.localStorage.setItem(key, value);
#elseif (cpp || hl)
sys.io.File.saveContent('save_' + key + '.txt', value);
#else
throw 'Storage not supported on this target';
#end
}
}
To verify, build the same class for two targets from your project root (a standard Haxe 4.x install is all you need):
- JavaScript:
haxe -main Main -js main.js. The generatedmain.jsshould contain thelocalStoragecall and no reference tosys.io.File— grep the file to confirm. - HashLink:
haxe -main Main -hl main.hl, which produces bytecode you run with the HashLink VM. The browser API should be absent from the build.
One gotcha: a variable or import that exists only inside an active branch does not exist for other targets, so code that compiles cleanly today can fail the moment you switch targets.
The Trade-Off: Errors Hiding in Inactive Branches
The compiler does not type-check the contents of an inactive #if block against the current target. You can introduce a type mismatch in the cpp branch while working exclusively against js, and nothing surfaces until someone builds for the other target. Heavy use of custom flags compounds this by making the build matrix — and onboarding for new developers — harder to follow.
Three habits keep it manageable:
- Limit scope: gate logic at the lowest level, inside a method of a wrapper class, rather than wrapping whole classes or business logic.
- Keep signatures identical: every branch should take the same arguments and return the same type so callers never know which one compiled.
- Compile every target in CI: building all supported targets on each push means a change for one platform cannot silently break another.
A Practical Next Step
If your project is drifting toward nested #if trees, treat that as a signal to extract target-specific classes behind a shared interface instead — conditional compilation then appears only in one small factory or typedef selection. For something you can do today: find one runtime platform check, replace it with a compile-time #if block, rebuild for two targets, and grep each output to confirm the other platform's code is gone.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.