Conditional Compilation in Haxe: When #if Helps and When It Hurts
Haxe's #if directives strip code before type checking, making them ideal for dependency management but risky for divergent logic. Learn where conditional compilation fits and when to use target-specific classes instead.
20 Jun 2026, 08:58 UTC

The Problem: A Quick Fix That Becomes Architecture
You start a Haxe project targeting both JavaScript and C++. The APIs diverge immediately: the browser has js.Browser, desktop has sys.io.File. The fastest path is to wrap the differences in #if blocks and move on. Six months later, a single class contains forty conditional branches, and adding a HashLink target means auditing every file.
The thesis of this piece is simple: conditional compilation in Haxe is a dependency-management tool, not a behavior-management tool. It removes code before the type checker runs, which makes it perfect for hiding unavailable imports and externs. It is the wrong tool for logic that merely behaves differently on each platform.
What #if Actually Does
Haxe evaluates #if, #elseif, and #else during an early compilation phase. The compiler discards inactive branches entirely; they never reach the target language generator and never participate in type checking.
This has two practical consequences. First, you can safely reference modules that exist only on one target inside a conditional block without breaking other targets. Second, because inactive branches are stripped before typing, they cannot enforce consistent function signatures across targets. If a #if js branch returns a String and a #if cpp branch returns an Int, the compiler will not compare them; it only sees the branch relevant to the current target.
Target identifiers are case-sensitive and specific: js, cpp, hl, and so on. A typo like JS or Cpp silently evaluates to false, which can produce confusing "missing field" errors from the active branch rather than a clear conditional-compilation diagnostic.
Abstract Types: Erased, Not Adaptive
Abstracts are compile-time wrappers around existing types. They let you attach methods and enforce type safety without a runtime wrapper class. However, they do not automatically switch their underlying representation per target. An abstract declared as abstract Foo(String) is a String on every target unless you explicitly use conditional compilation inside the abstract definition to choose a different underlying type.
Treat abstracts as a way to create domain-specific types over primitives—like a UserId(Int) or a ColorCode(Int)—rather than as a platform-abstraction mechanism. Calling them "zero-cost" is misleading: while the wrapper is erased, implicit casts and inlining can still introduce subtle overhead or unexpected boxing if the abstract exposes operators or is used in generic contexts.
A Better Pattern: Divergent Behavior Behind an Interface
When two targets need different logic for the same operation, keep the business logic clean and isolate the platform code. Define a common interface, implement it once per target, and use a single conditional block at the application's entry point to select the implementation.
Here is a logging example. The interface defines the contract; each target class contains only the code that can compile on that target.
// Logger.hx
interface Logger {
public function log(message: String): Void;
}
// LoggerJs.hx
class LoggerJs implements Logger {
public function log(message: String): Void {
js.Browser.console.log(message);
}
}
// LoggerCpp.hx
class LoggerCpp implements Logger {
public function log(message: String): Void {
try {
sys.io.File.append("app.log", message + "\n");
} catch (e: Dynamic) {
trace("Failed to write log: " + e);
}
}
}
// Main.hx
class Main {
static function main() {
var logger: Logger;
#if js
logger = new LoggerJs();
#elseif cpp
logger = new LoggerCpp();
#else
logger = new FallbackLogger();
#endif
logger.log("Application started");
}
}
The #if blocks now live in exactly one place. The rest of the codebase depends on Logger, not on #if. If you add a new target, you write one new class and add one branch to the factory, rather than hunting through every method.
Verification, Prerequisites, and Risks
To compile the JavaScript target, run the following from your project root:
haxe -main Main -js main.js
For the C++ target, the command is:
haxe -main Main -cpp out
However, C++ output requires the hxcpp library and a configured C++ toolchain (such as GCC, Clang, or MSVC). If hxcpp is missing, the compiler will fail before generating code.
Verifying that stripping occurred by searching generated C++ files for strings like "app.log" is unreliable. Compiler optimizations, name mangling, and platform differences can move or obscure literals. A more dependable check is to compile for JavaScript and confirm that the LoggerCpp class and its sys.io.File reference do not appear in the generated main.js. For C++, use a debugger such as GDB or LLDB to set a breakpoint in LoggerCpp.log and confirm it executes.
Compiler behavior and standard library APIs evolve across versions. Verify specific directives and externs against the current Haxe language guide for the version you are using.
The main risk in this pattern is interface drift. Because inactive #if branches are stripped before type checking, a target-specific class can accidentally fail to implement the full interface if you later add a method to Logger and forget to update every implementation. The compiler will catch this when you compile for that target, but only if you actually compile for all targets regularly.
Trade-offs and Limitations
Conditional compilation is indispensable when a target lacks a library or extern entirely. Without it, the JavaScript compiler would choke on sys.io.File even if the code never runs. But when the underlying platform APIs are similar and only the implementation details differ, scattering #if through method bodies creates a fragmented codebase that is hard to read and harder to test.
The interface pattern is not free. It adds a small amount of indirection and requires discipline to keep implementations in sync. For truly trivial one-line differences, a single #if inside a method may still be the pragmatic choice. The goal is to reserve #if for boundaries where the type system cannot help you, and use ordinary interfaces everywhere else.
Actionable Checklist
- Use
#iffor unavailable dependencies: imports, externs, and native libraries that do not exist on all targets. - Use interfaces for divergent behavior: when the same operation needs different logic per platform.
- Use abstracts for type-safe primitives: when the underlying representation is identical across targets and you want compiler-enforced domain rules.
- Compile for every target in CI: because inactive branches are stripped before type checking, only a real compilation catches missing implementations or signature drift.
- Verify with target-specific tools: browser dev tools for JavaScript, debuggers for C++, rather than string-searching generated output.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.