---
title: Drive DefinePlugin Substitutions From a Config File
impact: MEDIUM-HIGH
impactDescription: prevents feature-flag drift across environments
tags: transform, define, feature-flags, configuration
---

## Drive DefinePlugin Substitutions From a Config File

## Problem

Your app has feature flags compiled at build time: `if (__FLAGS__.newCheckout) { /* ... */ }`. These flags differ per environment — staging enables more experiments than prod. Encoding them in `webpack.config.js` means every PR touches the config (merge conflicts in CI on every release branch); duplicating the DefinePlugin call across configs (one for dev, one for staging, one for prod) means flag changes need 3 PRs.

You want a single `flags/staging.json` + `flags/production.json` file that the build picks up automatically based on env, with type generation, build-cache awareness (cache must invalidate when the flag file changes), and a runtime sanity check that flags actually got substituted (not left as `__FLAGS__`).

## Pattern

In `beforeRun`, read the configured flag file once, register it as a `buildDependency` (so persistent cache invalidates on edit), instantiate `DefinePlugin` with the flat-key-to-value substitutions, and inject a tiny runtime check that throws on first run if a flag wasn't substituted (catches misconfigurations).

**Incorrect (without a plugin — DefinePlugin called from webpack.config.js):**

```js
// webpack.config.js — long-form for each environment
const flags = process.env.NODE_ENV === 'production'
  ? require('./flags/production.json')
  : require('./flags/staging.json');

new webpack.DefinePlugin({
  __FLAGS__: JSON.stringify(flags),
  '__FLAGS__.newCheckout': JSON.stringify(flags.newCheckout),
  // ...for every flag — drifts when you add a new one
});

// No `buildDependencies` on flags/*.json → persistent cache reuses across edits
// No type generation → flags.someTypo compiles fine
```

**Correct (with this plugin — file-driven, cache-correct, types generated):**

```js
const fs = require('node:fs');
const path = require('node:path');
const { validate } = require('schema-utils');

const schema = {
  type: 'object',
  properties: {
    flagsFile: { type: 'string', minLength: 1 },
    globalName: { type: 'string', minLength: 1, description: 'Identifier (default __FLAGS__)' },
    declarationFile: { type: 'string', description: 'Optional path to emit .d.ts' },
    strictRuntimeCheck: { type: 'boolean', description: 'Throw at runtime if any flag wasn\'t substituted' },
  },
  required: ['flagsFile'],
  additionalProperties: false,
};

const DEFAULTS = { globalName: '__FLAGS__', strictRuntimeCheck: true };

class DefineFromConfigPlugin {
  constructor(options) {
    validate(schema, options, { name: 'DefineFromConfigPlugin', baseDataPath: 'options' });
    this.options = { ...DEFAULTS, ...options };
    this.flagsFile = path.resolve(options.flagsFile);
  }

  apply(compiler) {
    const PLUGIN = 'DefineFromConfigPlugin';
    const { DefinePlugin } = compiler.webpack;

    // Read flags ONCE per compilation (in beforeCompile so it's available for DefinePlugin's apply)
    let flags = null;
    const readFlags = () => {
      flags = JSON.parse(fs.readFileSync(this.flagsFile, 'utf8'));
    };
    readFlags(); // initial read at apply time

    // Register flag file as build dependency for persistent cache
    compiler.hooks.beforeCompile.tap(PLUGIN, (params) => {
      params.buildDependencies = params.buildDependencies ?? new Set();
      params.buildDependencies.add(this.flagsFile);
    });

    // Watch invalidation
    compiler.hooks.thisCompilation.tap(PLUGIN, (compilation) => {
      compilation.fileDependencies.add(this.flagsFile);
    });

    compiler.hooks.watchRun.tap(PLUGIN, readFlags);
    compiler.hooks.run.tap(PLUGIN, readFlags);

    // Build the definition map — both whole-object and per-key
    const defines = { [this.options.globalName]: JSON.stringify(flags) };
    for (const key of Object.keys(flags)) {
      defines[`${this.options.globalName}.${key}`] = JSON.stringify(flags[key]);
    }
    new DefinePlugin(defines).apply(compiler);

    // Optional: emit .d.ts for type safety
    if (this.options.declarationFile) {
      this.emitDeclaration(compiler, flags);
    }

    // Optional: runtime check
    if (this.options.strictRuntimeCheck) {
      this.injectRuntimeCheck(compiler);
    }
  }

  emitDeclaration(compiler, flags) {
    const { ProvidePlugin } = compiler.webpack;
    const indent = '  ';
    const typeOf = (v) =>
      v === null ? 'null'
      : typeof v === 'boolean' ? 'boolean'
      : typeof v === 'number' ? 'number'
      : typeof v === 'string' ? 'string'
      : 'unknown';

    const lines = [
      `// Auto-generated by DefineFromConfigPlugin`,
      `declare const ${this.options.globalName}: {`,
      ...Object.keys(flags).map((k) => `${indent}readonly ${k}: ${typeOf(flags[k])};`),
      `};`,
      ``,
    ].join('\n');

    fs.mkdirSync(path.dirname(this.options.declarationFile), { recursive: true });
    fs.writeFileSync(this.options.declarationFile, lines);
  }

  injectRuntimeCheck(compiler) {
    // Inject a tiny runtime guard as a separate entry that runs first
    const { EntryPlugin } = compiler.webpack;
    const check =
      `if (typeof ${this.options.globalName} === 'undefined') {\n` +
      `  throw new Error('${this.options.globalName} was not substituted at build time. ` +
      `Check DefineFromConfigPlugin is registered before any other plugin that processes the entry.');\n` +
      `}\n`;
    const checkPath = path.join(compiler.context, '__flag_check__.js');
    // Use the virtual-module trick (omitted here for brevity)
    // ...
  }
}

module.exports = DefineFromConfigPlugin;
```

## Usage

```js
// webpack.config.js
new DefineFromConfigPlugin({
  flagsFile: `flags/${process.env.DEPLOY_ENV ?? 'development'}.json`,
  declarationFile: 'src/types/generated/flags.d.ts',
  strictRuntimeCheck: process.env.NODE_ENV !== 'production',
})

// flags/staging.json
{ "newCheckout": true, "darkMode": true, "experimentalSearch": true }

// flags/production.json
{ "newCheckout": false, "darkMode": true, "experimentalSearch": false }

// Source:
if (__FLAGS__.newCheckout) {
  return <NewCheckout />;
}
if (__FLAGS__.darkmode) {  // TS error: Property 'darkmode' does not exist
  // ...
}
```

## How it works

- **`buildDependencies.add(flagsFile)`** is the persistent-cache fix — without it, the cache from staging serves to production with wrong flags. This is the same fix as [`webpack-plugin-authoring/cache-build-dependencies-for-persistent-cache`].
- **`fileDependencies.add(flagsFile)`** is the watch-mode fix — editing the flag file triggers a dev rebuild. See [`webpack-plugin-authoring/cache-add-file-dependencies`].
- **Per-key defines** (`__FLAGS__.newCheckout` and not just `__FLAGS__`) — webpack's DefinePlugin substitutes both forms, but per-key form is critical for tree-shaking: `if (__FLAGS__.newCheckout)` becomes `if (true)` and the dead branch is removed; whereas `if (__FLAGS__.newCheckout)` reading from the whole object substitution stays as a property access terser can't eliminate
- **Reading in `apply()` AND `watchRun`/`run`** — the first read enables DefinePlugin setup; the second handles cases where the flags file changed between dev server start and the next build

## Variations

- **Multiple flag files** (merging product + experiment + ops flags): take `flagsFiles: string[]` and shallow-merge
- **YAML/TOML** (not just JSON): swap the parser
- **Remote flags fetched at build time** (LaunchDarkly, Statsig — fetch in `beforeRun`):
  ```js
  compiler.hooks.beforeRun.tapPromise(PLUGIN, async () => {
    flags = await fetchRemoteFlags();
  });
  ```
- **Per-chunk flag injection** (different bundles see different flags): apply DefinePlugin to specific entry compilers in MultiCompiler

## When NOT to use this pattern

- Flags need to change at runtime (use a flag SDK like LaunchDarkly, Statsig, GrowthBook — they handle this)
- You have <5 flags — direct `DefinePlugin({ __FLAGS__: {...} })` is fine
- Flags are server-only (don't substitute at build; read from env at runtime)

Reference: [DefinePlugin](https://webpack.js.org/plugins/define-plugin/) · [GrowthBook feature flags](https://www.growthbook.io/) · [Persistent caching](https://github.com/webpack/changelog-v5/blob/master/guides/persistent-caching.md)
