Using Conditional Compilation Flags in TypeScript: Patterns, Tools, and Best Practices
Introduction
Conditional compilation (also called build-time feature flags or compile-time pruning) lets you include or exclude code paths from a final JavaScript bundle based on build-time constants. In TypeScript projects this pattern is invaluable for shipping smaller production bundles, enabling debug-only behavior, gating experimental features, and tailoring library builds for different target environments. However, because TypeScript itself does not provide a built-in preprocessor or conditional compilation directive, implementing safe and maintainable flags requires deliberate patterns that combine type-level techniques, bundler configuration, and careful coding style.
In this tutorial for intermediate TypeScript developers you'll learn practical approaches to implement conditional compilation flags: how to declare and type flags, replace them at build-time, rely on tree-shaking and minification to remove dead code, and use type-level constructs where appropriate. We'll provide multiple working examples using common bundlers (webpack, Rollup, esbuild, Vite), discuss trade-offs, demonstrate runtime vs build-time decisions, and show how to test and debug flagged code. By the end you'll have patterns to confidently add compile-time flags to both applications and libraries while keeping your type safety intact.
Background & Context
TypeScript compiles to JavaScript; it does not provide a native directive like C's #ifdef. Conditional compilation in the TypeScript ecosystem is usually achieved by combining: (1) build-tool replacements (e.g., replacing a symbol with a literal boolean), (2) minifiers/tree-shakers that remove unreachable code (for example, if (false) { ... } blocks), and (3) type-level checks that guide developers and provide compile-time guarantees without runtime cost. Understanding how bundlers, minifiers, and TypeScript interact is crucial: the compiler erases types, while bundlers and minifiers operate on emitted JavaScript, so correct replacement and elimination depends on predictable emitted code patterns.
This tutorial emphasizes both practical build setups and type-safe coding practices. We'll also touch on pitfalls like relying on non-constant expressions, misuse of dynamic code constructs (eval/new Function), and mismatches between runtime and type-level behavior. For adjacent topics like ensuring correct shapes in runtime data, you may find our guide on Typing JSON Payloads from External APIs (Best Practices) useful.
Key Takeaways
- Conditional compilation in TypeScript requires combining build-time replacements with dead-code elimination.
- Declare flags as constants, type them, and make them easy to replace by bundlers.
- Avoid non-static checks (e.g., process.env access without bundler replacement); prefer build-time define plugins.
- Use type-level patterns for compile-time guarantees and to avoid runtime overhead.
- Test flags across build targets and CI; use feature toggles for gradual rollouts.
Prerequisites & Setup
Before continuing, you should be comfortable with TypeScript basics, npm scripts, and a bundler (webpack, Rollup, esbuild, or Vite). We'll provide examples for esbuild and webpack. Install Node.js (LTS), TypeScript, and a bundler of your choice. Example commands:
- npm init -y
- npm i -D typescript esbuild webpack terser rollup vite
Also have tsconfig.json in your project root. For typing helper patterns we’ll use const assertions and declaration merging, so familiarity with these TypeScript features is useful—see the guide on When to Use const Assertions (as const) in TypeScript: A Practical Guide for more details.
Main Tutorial Sections
1) What Conditional Compilation Means in TypeScript (Practical Definition)
Conditional compilation in TypeScript typically means: guarantee that a piece of code is excluded from the emitted production bundle under certain compile-time conditions. Since TypeScript strips types, conditional compilation is implemented using build-time literal replacements (e.g., replacing DEV with false) and relying on minifiers to remove unreachable code. The pattern is: declare an identifier that the bundler will replace, wrap debug-only code in if (DEV) { ... }, then ensure the build replaces DEV with false, letting the minifier drop the block entirely.
Example (source):
declare const __DEV__: boolean;
if (__DEV__) {
console.debug('dev-only message', { some: 'info' });
}This declaration provides typing for developers and avoids TypeScript errors while the bundler handles replacement.
2) Declaring and Typing Flags Safely
Always provide a TypeScript declaration for build-time flags to avoid compiler errors and improve DX. Create a file src/env.d.ts with ambient declarations:
declare const __DEV__: boolean; declare const __FEATURE_X__: boolean;
Because these are compile-time replaced, use the declare form rather than exporting constants. To get stronger inference or to combine multiple flags, you can declare an interface for a global __APP_FLAGS__ object. When flags affect types or API shapes, combine them with conditional types or satisfies to preserve intent. For example, using Using the satisfies Operator in TypeScript (TS 4.9+) can help document expected flag objects while keeping inference tight.
3) Build-Time Replacement with Bundlers (esbuild, webpack, Rollup)
Most bundlers provide a define/replace option to substitute identifiers with compile-time literals. Examples:
- esbuild:
define: { '__DEV__': 'false' } - webpack:
new webpack.DefinePlugin({ __DEV__: JSON.stringify(false) }) - Rollup:
@rollup/plugin-replace
esbuild example script:
require('esbuild').build({
entryPoints: ['./src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
define: { '__DEV__': 'false' },
});Using define ensures if (__DEV__) becomes if (false) in emitted JS, enabling minifiers to drop the branch.
4) Relying on Dead-Code Elimination & Tree-Shaking
Replacing a flag with a literal only helps if the minifier/tree-shaker removes the unreachable branch. Terser, esbuild, and many bundlers implement dead-code elimination (DCE) when they see constant conditions. Keep your debug-only code in simple conditional blocks (if/else, ternaries) and avoid wrapping them in functions that appear to have side effects. Example:
if (__DEV__) {
enableDevLogging();
}
function enableDevLogging() {
// heavy debug-only library usage
}If enableDevLogging is only referenced inside the removed branch, tree-shaking can remove the function entirely. But if that function is exported and used elsewhere, it won't be removed—so design your module boundaries for DCE.
5) Type-Level Conditional Compilation with Conditional Types
Sometimes you want compile-time (type-level) differences, e.g., an API returns a narrow shape in production but a richer shape in dev. Use conditional types and generic flags to express these differences without runtime checks. Example:
type FeatureXEnabled<T extends boolean> = T extends true ? { debug: string } : { debug?: never };
function getResponse<T extends boolean>(flag: T): FeatureXEnabled<T> {
// runtime implementation is symmetric; prefer two separate implementations when possible
return {} as any;
}Remember: Type-level branching doesn't remove runtime code. Use type-only constructs to guide DX and pair them with build-time replacements for runtime elimination where necessary. For more strategies on typing functions that vary in return types, see Typing Functions with Multiple Return Types (Union Types Revisited).
6) Preprocessors and Plugin Tools (ifdef, babel plugins)
If your needs go beyond simple define replacements (for example, you want to remove whole files or support complex directives), preprocessors can help. Tools like ifdef-loader for webpack or babel-plugin-transform-inline-environment-variables let you write more expressive conditional blocks. Example of a preprocessor-style comment:
/* @if FEATURE_X */ import heavyFeature from './heavyFeature' /* @endif */
Use preprocessors sparingly: they introduce a second language and can make debugging harder. Prefer define + DCE when possible because the emitted JS stays standard and easier to reason about. Also avoid dynamic eval or new Function() based approaches; they complicate static analysis and bundler optimizations—see our cautionary notes on Typing Functions That Use eval() — A Cautionary Tale and Typing Functions That Use new Function() (A Cautionary Tale).
7) Environment-Based Config Patterns: Runtime vs Compile-Time
Decide whether a flag truly needs to be compile-time. Runtime flags allow toggling without rebuilding (useful for feature flags and A/B tests), but compile-time flags enable size and performance benefits. A hybrid pattern works well: use compile-time flags for debug-only code and heavy diagnostics, and runtime feature toggles for user-facing behavior. For runtime config shapes (e.g., JSON responses), combine compile-time flags with robust validation—our guide on Typing JSON Payloads from External APIs (Best Practices) covers validating and typing runtime data.
8) Testing and Debugging Flagged Code
Set up CI builds that test both dev and prod replacements. Create npm scripts for build:dev and build:prod with different define sets. Example package.json scripts:
{
"scripts": {
"build:dev": "esbuild --define:__DEV__=true src/index.ts --outfile=dist/dev.js",
"build:prod": "esbuild --define:__DEV__=false src/index.ts --minify --outfile=dist/prod.js"
}
}Write unit tests that run against both builds or simulate compile-time replacements by setting global variables in test harnesses. Use source maps in dev builds to trace code.
9) Practical Example: Feature Flagging in a Library and App
Imagine a small library that offers additional debug introspection during development. Library source:
// lib/trace.ts
declare const __LIB_TRACE__: boolean;
export function trace(msg: string) {
if (__LIB_TRACE__) {
// heavy computation or large dependency
console.debug('[TRACE]', msg);
}
}Library consumers can build with __LIB_TRACE__ false to drop tracing. If your library exposes types that depend on the flag, use separate entry points (lib/index.dev.ts vs lib/index.prod.ts) and document how to compile for each target. When shipping to npm, publish both ES and CJS bundles and use the consumer's bundler define plugin to get dead-code elimination.
10) Integrating Type Safety for Flag-Driven APIs
If a flag changes exported API shapes, provide explicit types and separate signatures to avoid confusing downstream consumers. For example, use overloads or separate entry points and document them clearly. When a function's signature changes based on a flag, prefer separate functions (e.g., createClientDev vs createClientProd) instead of trying to make a single signature that morphs at build-time; this keeps type definitions straightforward. For advanced type strategies relating to optional parameters and variadic APIs, our guide on Typing Functions with Variable Number of Arguments (Rest Parameters Revisited) is useful.
Advanced Techniques (Expert Tips)
- Use const enum pattern carefully. const enums can be inlined by TypeScript when
preserveConstEnumsis false, but modern bundlers and tools sometimes struggle with them; prefer simple define replacements for clarity. - Combine feature flags with runtime capability checks. For example, compile-time flags can gate the inclusion of a polyfill bundle, while runtime checks verify environment features before executing code.
- Modularize heavy debug code into separate modules to maximize tree-shaking. Import these modules only from debug-only branches so they can be dropped entirely in production builds.
- Use stricter TypeScript compiler settings (noImplicitAny, strictNullChecks) so type-level invariants help you refactor flagged code safely. For object shape enforcement, consider patterns from Typing Objects with Exact Properties in TypeScript to prevent accidental leakage of dev-only properties.
- If flags affect error shapes or handling, keep a shared type library for error objects and consult Typing Error Objects in TypeScript: Custom and Built-in Errors to maintain consistency and runtime guards.
Best Practices & Common Pitfalls
Do:
- Declare flags in a single ambient
.d.tsto avoid missing declarations. - Keep conditional blocks simple and free of dynamic runtime checks so they are clearly removable.
- Modularize debug-only code into separate modules so tree-shakers can remove them.
- Document build scripts and provide both dev and prod build variants.
Don't:
- Don’t rely on reading process.env directly in code without bundler replacement—it won't be a compile-time constant and DCE won't work reliably.
- Avoid using dynamic code generation (eval/new Function) for flags; these patterns can bypass static analysis and break tree-shaking—see related caveats in our articles on Typing Functions That Use eval() — A Cautionary Tale and Typing Functions That Use new Function() (A Cautionary Tale).
- Don't mix many flags that affect API shapes in the same module. If multiple flags change behavior combinatorially, prefer separate builds or explicit factory functions.
Troubleshooting:
- If flagged code is still in your prod bundle, verify that the replacement actually produced
if (false)in emitted JS and that minification is enabled. - Use source maps and inspect the bundle to find unexpected imports that keep dependencies alive.
- Run a size analysis (e.g., source-map-explorer or webpack-bundle-analyzer) to see what code remains.
Real-World Applications
- Application builds: drop large debug-only telemetry and consoles from production by defining DEV = false in production builds.
- Libraries: ship smaller consumer bundles by gating optional heavy features (e.g., advanced logging or dev tools) behind flags that consumers set at their build time.
- Multi-target builds: produce distinct bundles for Node, browser, and embedded environments by switching flags for environment-specific code.
- Progressive rollout: use runtime feature flags for user-level toggles but compile-time flags for heavy instrumentation used by developers only.
In projects that parse or validate runtime payloads, pair compile-time flags with robust validation to ensure you don't ship assumptions—our guide on Typing JSON Payloads from External APIs (Best Practices) offers patterns for safe runtime data handling.
Conclusion & Next Steps
Conditional compilation in TypeScript unlocks powerful optimizations and cleaner production bundles but requires coordination between types, code structure, and build tooling. Start by declaring flags in a central .d.ts, use define/replace options in your bundler, and structure code for tree-shaking. Gradually introduce flags and maintain tests for both dev and prod builds. Next, explore advanced typing patterns and build-optimized API design by reading the linked resources in this article.
Recommended next steps:
- Implement a simple DEV flag in a small repo to validate DCE behavior.
- Run bundle analysis to measure gains.
- Read up on related typing topics linked below to maintain type safety as you add complexity.
Enhanced FAQ
Q: Does TypeScript support #ifdef-style preprocessor directives? A: Not natively. TypeScript is a typed superset of JavaScript and does not include a preprocessor. Conditional compilation is achieved by replacing identifiers at build-time (via bundler define/replacement plugins) and relying on minifiers/tree-shakers to remove unreachable code. For more complex directives you can use preprocessors or build steps, but those add complexity and often make debugging harder.
Q: How should I declare flags so that TypeScript won't complain?
A: Use ambient declarations in a .d.ts file. For example:
declare const __DEV__: boolean;
This gives you type safety and prevents TS errors in editor and builds. You can also group flags under a global __APP_FLAGS__ object and type it using satisfies to keep inference precise—see Using the satisfies Operator in TypeScript (TS 4.9+).
Q: Will replacing flags with define always lead to smaller bundles?
A: Only if the conditional code is structured so minifiers can remove it. Use literal replacements producing if (false) or if (true) and enable minification. Avoid patterns with dynamic checks or exported functions referenced elsewhere—those will typically remain in the bundle.
Q: What's the difference between compile-time flags and runtime feature toggles? A: Compile-time flags are replaced during build and can remove code entirely, shrinking bundles and eliminating runtime cost. Runtime toggles allow switching behavior without rebuilding (useful for experiments and rollouts) but cannot remove bundled code. Many projects use a hybrid approach: compile-time flags for debug-only or heavy instrumentation; runtime toggles for user-facing feature rollouts.
Q: Can flags change TypeScript types (e.g., API shapes) safely? A: Type-level conditional types can express different shapes depending on boolean type parameters, but these do not change runtime code. If you need different runtime shapes, prefer separate entry points or factory functions for each build target. For functions that return different types, learn strategies from Typing Functions with Multiple Return Types (Union Types Revisited).
Q: My library includes heavy debug utilities. How do I ensure consumers drop them?
A: Put heavy utilities behind if (__LIB_DEBUG__) { import './heavy'; } style guards and ensure consumers configure their bundler to replace __LIB_DEBUG__ with false. Alternatively publish separate entry points for debug and production builds. For object shapes and strict exports, consult Typing Objects with Exact Properties in TypeScript.
Q: Are there risks using preprocessors to remove code? A: Yes. Preprocessors introduce a second parsing layer and custom syntax which can confuse editors, linters, and other tooling. They also make source mapping and debugging harder. Prefer define-based replacements when possible. If you must use a preprocessor, keep directives minimal and document them well.
Q: How do I test both dev and prod paths in CI?
A: Add CI jobs that run both build:dev and build:prod, then run unit tests against the built artifacts or run size and smoke tests. Use scripts that set define values for the bundler to ensure reproducible builds. Also run tests that assert that production bundles don't contain dev-only strings (e.g., console.debug occurrences).
Q: How do flags interact with error handling and logging types? A: If flags enable different logging or error shapes, centralize error type definitions and use runtime guards for validation. Our article on Typing Error Objects in TypeScript: Custom and Built-in Errors gives patterns for consistent error typing and runtime checks.
Q: Where can I learn more about typing strategies that pair well with conditional compilation? A: Explore guides on typing JSON payloads, exact object properties, and generators/iterators if you use streams or feature-driven APIs. Some helpful reads: Typing JSON Payloads from External APIs (Best Practices), Typing Objects with Exact Properties in TypeScript, and Typing Generator Functions and Iterators in TypeScript — An In-Depth Guide.
Q: What are common indicators that my flagged code wasn't removed correctly?
A: Production bundle contains debug strings (like debug or TRACE), unexpected large dependencies are present, or initial load times haven't improved after enabling flags. Use bundle analysis tools and inspect the emitted JS to confirm replacements occurred.
Q: Can I use flags to remove entire files? A: You can, by ensuring the only import that would pull the file is inside a removed branch. Example:
if (__DEV__) {
require('./dev-only');
}This can work if the module system and bundler support conditional requires/imports and DCE applies. However, prefer dynamic imports or separate entry points for clarity.
If you'd like, I can generate specific build configs for webpack, Rollup, and esbuild tailored to your project, or show a sample repo demonstrating the full flow (source -> define replacement -> emitted JS -> minified bundle) so you can verify DCE locally. I can also map typed feature flags to tooling setups if you share your preferred bundler.
