| | |

esbuild 15 ⚡ The define Feature — Compile-time constant replacement

⏱️ Estimated read: 11 min | |

The define feature replaces identifiers with constant expressions at build time. When esbuild encounters a configured identifier—such as process.env.NODE_ENV or __APP_VERSION__—it substitutes the identifier with the configured value before any other transformation runs. The substitution happens in the parser, so the replaced value participates in constant folding and dead-code elimination. A block guarded by if (process.env.NODE_ENV !== "production") becomes if ("production" !== "production"), which esbuild then folds to false, and the entire block is removed from the bundle .

This chapter covers the define option’s syntax, the string-quoting requirement that trips up most first-time users, the constant-folding and dead-code elimination that make the feature useful, and the security boundary that separates a safe compile-time constant from a leaked secret.

Key point: The values in define are parsed as JavaScript expressions, not as strings. A string constant must be quoted inside the value, so JSON.stringify("production") is required for a string. An unquoted production is parsed as an identifier, and the output contains a reference to a variable that does not exist.


Why define exists

The environment problem. A library or application often needs to behave differently in development and production. The conventional pattern is process.env.NODE_ENV. In a Node.js runtime, the value is read from the environment at startup. In a browser bundle, there is no process object, and the value must be baked into the bundle. The define feature is the mechanism that bakes the value into the bundle at build time .

The dead-code problem. A conditional that depends on a compile-time constant can be eliminated entirely. The if (process.env.NODE_ENV !== "production") pattern is the standard example: when the value is replaced with "production", the condition folds to false, and the development-only code is removed. The result is a smaller bundle and no runtime cost for the check .

The feature-flag problem. A build can define __FEATURE_X__ as true or false and use it to gate an experimental code path. The flag is resolved at build time, so the disabled path never reaches the bundle. The same mechanism powers the //#if defined(DEBUG) preprocessor patterns that some build tools layer on top of esbuild .


a. The syntax and the string-quoting rule

The define option is an object where each key is the identifier to replace and each value is the replacement expression. The value is a string, and the string is parsed as JavaScript code, not as a literal string.

// esbuild.build.js
await esbuild.build({
  define: {
    'process.env.NODE_ENV': '"production"',
    '__APP_VERSION__': '"1.0.0"',
    '__DEBUG__': 'false',
    '__MAX_RETRIES__': '3',
  },
})

The process.env.NODE_ENV key is replaced with the expression "production", which is a string literal because the value includes the quotes. The __DEBUG__ key is replaced with the boolean false. The __MAX_RETRIES__ key is replaced with the number 3.

The quoting requirement is the most common source of confusion. A value of production without the inner quotes is parsed as an identifier, and the output contains if (production !== "production"), which references a variable that does not exist. The JSON.stringify() helper is the idiomatic way to produce the quoted string:

define: {
  'process.env.NODE_ENV': JSON.stringify('production'),
}

The JSON.stringify('production') returns the string "\"production\"", which is the quoted expression that define expects .

The value must be a JSON-serializable value or a single identifier. An object literal is not supported as a top-level value, because replacing every reference with an inline object literal would bloat the bundle. The inject feature is the mechanism for that use case .


b. Constant folding and dead-code elimination

The substitution happens before the minifier runs, which is what makes the dead-code elimination possible. The minifier sees the folded condition and removes the unreachable branch.

// Source
if (process.env.NODE_ENV !== 'production') {
  enableDevTools()
}
// After define (process.env.NODE_ENV = "production")
if ("production" !== 'production') {
  enableDevTools()
}
// After constant folding
if (false) {
  enableDevTools()
}
// After dead-code elimination
// (the entire block is removed)

The define substitution is recursive. If the replacement value itself contains an identifier that is also defined, the nested identifier is replaced as well. The feature is designed for constants, not for runtime values, and the documentation recommends using it only for values that do not change at runtime .


c. The security boundary

The define feature embeds the value directly into the bundle. If the value is a secret—an API key, a database password, a token—the secret is visible in the output JavaScript, and anyone who downloads the bundle can read it.

// SECURITY RISK: the API key is embedded in the bundle
define: {
  'process.env.SECRET_API_KEY': '"sk_live_abc123"',
}

The security analysis of esbuild’s attack surface lists this as a critical risk: “Substitution of global identifiers with constant expressions, potentially exposing sensitive data” . The mitigation is straightforward: never put a secret in define. The value that reaches the browser must be a public value—a version string, a feature flag, a public API endpoint. A secret belongs in a server-side runtime environment, not in a client bundle.

A subtler variant of the same problem is replacing process.env with the entire environment object. The documentation warns against this because it would embed every environment variable—including the secrets—into the bundle . The correct pattern is to define each public variable individually.


Complete Example Session

// ============================================
// PART 1: THE STRING CONSTANT
// ============================================
await esbuild.build({
  define: {
    'process.env.NODE_ENV': JSON.stringify('production'),
  },
})

// The value is the quoted string "production".
// ============================================
// PART 2: THE UNQUOTED MISTAKE
// ============================================
await esbuild.build({
  define: {
    'process.env.NODE_ENV': 'production',
  },
})

// The value is parsed as an identifier.
// The output references a variable named "production".
// ============================================
// PART 3: THE BOOLEAN AND NUMBER
// ============================================
await esbuild.build({
  define: {
    '__DEBUG__': 'false',
    '__MAX_RETRIES__': '3',
  },
})

// The values are parsed as a boolean and a number.
// ============================================
// PART 4: THE DEAD-CODE ELIMINATION
// ============================================
// Source
if (__DEBUG__) {
  console.log('debug')
}

// After define (__DEBUG__ = false)
if (false) {
  console.log('debug')
}

// After minification: the block is removed.
// ============================================
// PART 5: THE NESTED REPLACEMENT
// ============================================
await esbuild.build({
  define: {
    'process.env.NODE_ENV': JSON.stringify('production'),
    'process.env': 'process.env',
  },
})

// The nested identifier is replaced recursively.
// ============================================
// PART 6: THE SECURITY MISTAKE
// ============================================
await esbuild.build({
  define: {
    'process.env.API_KEY': '"sk_live_abc123"',
  },
})

// The API key is embedded in the bundle.
// Anyone who downloads the bundle can read it.
// ============================================
// PART 7: THE SAFE PATTERN
// ============================================
await esbuild.build({
  define: {
    'process.env.API_URL': JSON.stringify('https://api.example.com'),
  },
})

// The public URL is safe to embed.
// The secret stays on the server.
// ============================================
// PART 8: THE ASSIGNMENT WARNING
// ============================================
// Source
process.env.NODE_ENV = 'development'

// esbuild warns: "Suspicious assignment to defined constant"
// It does not generate the invalid output "production" = "development".
// ============================================
// PART 9: THE MINIFICATION INTERACTION
// ============================================
await esbuild.build({
  define: { 'process.env.NODE_ENV': JSON.stringify('production') },
  minify: true,
})

// The define runs first, then the minifier
// removes the dead branches.
// ============================================
// PART 10: THE DECISION
// ============================================
// Is the value public and constant?
//   Yes → define it
// Is the value a secret?
//   No → keep it on the server
// Is the value an object?
//   Use inject, not define

The ten parts covered the string constant, the unquoted mistake, the boolean and number, the dead-code elimination, the nested replacement, the security mistake, the safe pattern, the assignment warning, the minification interaction, and the decision.


Quick Reference

The define option

PropertyDetail
KeyThe identifier to replace
ValueA JavaScript expression as a string
String constantRequires JSON.stringify() or inner quotes
Boolean'true' or 'false'
Number'3' or '3.14'
Object literalNot supported; use inject

Common patterns

PatternExample
Environment'process.env.NODE_ENV': JSON.stringify('production')
Version'__APP_VERSION__': JSON.stringify('1.0.0')
Feature flag'__FEATURE_X__': 'true'
Public URL'process.env.API_URL': JSON.stringify('https://...')

The security rules

RuleReason
Never define a secretThe value is embedded in the bundle
Never define process.envEmbeds every environment variable
Define public values individuallyLimits the embedded data
Use inject for objectsdefine is for constants, not structures

The interaction with minification

StepBehavior
1define substitutes the identifier
2Constant folding evaluates the expression
3Dead-code elimination removes the unreachable branch

Best Practices

✅ Do This:

// Quote the string value
'process.env.NODE_ENV': JSON.stringify('production')        // ✅
// Define public constants
'__APP_VERSION__': JSON.stringify('1.0.0')                  // ✅
// Use boolean and number values directly
'__DEBUG__': 'false'                                        // ✅
// Use inject for object values
inject: ['./env-shim.js']                                   // ✅

❌ Don’t Do This:

// Leave the string unquoted
'process.env.NODE_ENV': 'production'                        // ❌
// Embed a secret
'process.env.API_KEY': '"sk_live_abc123"'                   // ❌
// Define the entire process.env object
'process.env': JSON.stringify(process.env)                  // ❌
// Use define for an object literal
'__CONFIG__': '{ "debug": true }'                           // ❌ (code bloat)

Common Pitfalls

PitfallWhy It HappensFix
Output references an undefined variableString value was not quotedUse JSON.stringify()
Dead code is not removedMinification is disabledEnable minify: true
Bundle contains a secretSecret was definedRemove it; keep it server-side
Bundle is hugeObject literal definedUse inject instead
Assignment warningCode assigns to a defined constantRemove the assignment

Real-World Examples

1. The quoted string

define: { 'process.env.NODE_ENV': JSON.stringify('production') }

2. The unquoted mistake

define: { 'process.env.NODE_ENV': 'production' }

3. The boolean flag

define: { '__DEBUG__': 'false' }

4. The version constant

define: { '__APP_VERSION__': JSON.stringify('1.0.0') }

5. The dead-code elimination

if (__DEBUG__) { console.log('debug') }

6. The security mistake

define: { 'process.env.API_KEY': '"sk_live_abc123"' }

7. The safe public URL

define: { 'process.env.API_URL': JSON.stringify('https://api.example.com') }

8. The assignment warning

process.env.NODE_ENV = 'development'

9. The minification interaction

define: { ... }, minify: true

10. The decision

Public constant? → define
Secret? → server-side
Object? → inject

Visual

The substitution flow

┌─────────────────────────────────────────────────────────────┐
│  Source: if (process.env.NODE_ENV !== "production") { ... }  │
│       │                                                      │
│       ▼                                                      │
│  define: "production"                                        │
│       │                                                      │
│       ▼                                                      │
│  Folded: if ("production" !== "production") { ... }          │
│       │                                                      │
│       ▼                                                      │
│  Folded: if (false) { ... }                                  │
│       │                                                      │
│       ▼                                                      │
│  Removed: (the entire block)                                 │
└─────────────────────────────────────────────────────────────┘

The quoting rule

┌─────────────────────────────────────────────────────────────┐
│  define: { 'X': JSON.stringify('production') }               │
│    → value is "production" (a string literal)                │
│                                                              │
│  define: { 'X': 'production' }                               │
│    → value is production (an identifier)                     │
│    → the output references an undefined variable             │
└─────────────────────────────────────────────────────────────┘

The security boundary

┌─────────────────────────────────────────────────────────────┐
│  SAFE TO DEFINE              NEVER DEFINE                    │
│                                                              │
│  Public URLs                 API keys                        │
│  Version strings             Database passwords              │
│  Feature flags               Tokens                          │
│  Build timestamps            Anything secret                 │
└─────────────────────────────────────────────────────────────┘

The interaction order

┌─────────────────────────────────────────────────────────────┐
│  1. define substitution                                      │
│  2. constant folding                                         │
│  3. dead-code elimination                                    │
│  4. minification                                             │
│                                                              │
│  The define runs first, which is what enables the rest.      │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Optiondefine
PurposeCompile-time constant replacement
Value syntaxJavaScript expression as a string
String constantRequires JSON.stringify()
Boolean / number'true' / '3'
Object literalNot supported; use inject
Dead-code eliminationEnabled by constant folding
Security ruleNever define a secret
Assignment warningEmitted when code assigns to a defined constant

Key takeaways:

  • The define values are JavaScript expressions, not strings. A string constant must be quoted inside the value, which is why JSON.stringify() is the standard helper. An unquoted value is parsed as an identifier and produces a reference to an undefined variable .
  • The substitution enables dead-code elimination. The define runs before the minifier, so a conditional that depends on a constant folds to a boolean, and the unreachable branch is removed. This is the mechanism behind the process.env.NODE_ENV optimization .
  • The feature is for constants, not for objects. An object literal as a define value would be inlined at every reference, bloating the bundle. The inject feature is the mechanism for a shared value that is referenced rather than inlined .
  • A secret in define is a leaked secret. The value is embedded directly in the output bundle, which is served to the browser. The security analysis of esbuild lists this as a critical risk . The value that reaches the client must be a public value.
  • The assignment warning is a safety net. If the code assigns to a defined constant, esbuild emits a warning instead of generating the invalid output "production" = "development". The warning is a sign that the definition and the code are inconsistent .
  • The define substitution is recursive and syntax-aware. It replaces the identifier in expression position, not in property names or string literals, and it does not substitute into assignment position. The feature is designed for the constant-replacement use case, not for arbitrary text substitution.

Remember: The define feature is a compile-time constant replacement. The value is a JavaScript expression, so a string requires quoting. The substitution runs before minification, which is what makes the dead-code elimination possible. The feature is for public constants—versions, flags, public URLs—and never for secrets. And the assignment warning is a signal that the definition is being used in a way that does not make sense for a constant.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!