Read OSS

From Source to Bundle: Error Mangling, Multi-Format Builds, and Dev/Prod Divergence

Intermediate

Prerequisites

  • Articles 1-5
  • Familiarity with JavaScript bundlers (webpack, esbuild, or similar)
  • Basic understanding of Babel plugins

From Source to Bundle: Error Mangling, Multi-Format Builds, and Dev/Prod Divergence

Throughout this series, we've explored Redux's runtime code and type system. Now we turn to the question every library author faces: how do you ship code that works in every JavaScript environment while keeping bundles small and error messages helpful? Redux's answer is a sophisticated build pipeline that produces four distinct output formats, replaces error messages with numeric codes in production, and runs the full test suite against the built artifacts.

This final article traces the path from src/index.ts to the dist/ folder.

The tsup Build Configuration

Redux uses tsup, a zero-config bundler built on esbuild, to produce all four output formats from a single configuration:

tsup.config.ts#L47-L96

flowchart TD
    SRC["src/index.ts"] --> BABEL["Babel mangleErrors<br/>transform"]
    BABEL --> ESBUILD["esbuild"]

    ESBUILD --> ESM["redux.mjs<br/><i>ESM + sourcemap + .d.mts</i>"]
    ESBUILD --> LEGACY["redux.legacy-esm.js<br/><i>ESM, ES2017 target</i>"]
    ESBUILD --> BROWSER["redux.browser.mjs<br/><i>ESM, minified, NODE_ENV=production</i>"]
    ESBUILD --> CJS["cjs/redux.cjs<br/><i>CommonJS</i>"]

Four build targets emerge from the defineConfig return array:

Target Format Extension Special Settings
Standard ESM esm .mjs Generates .d.mts declarations, clean: true
Legacy ESM esm .js target: ['es2017'] for Webpack 4
Browser ESM esm .mjs process.env.NODE_ENV hardcoded to 'production', minified
CommonJS cjs .cjs Output to dist/cjs/ subdirectory

All four targets share a common base that includes the mangleErrorsTransform esbuild plugin — which brings us to the most interesting part of the build.

Error Mangling: The Babel Plugin

Redux adapts React's error code system. In production, error messages like "Actions must be plain objects" are replaced with numeric codes that point to a URL where the full message can be looked up. This dramatically reduces bundle size while preserving debuggability.

The pipeline works like this:

tsup.config.ts#L12-L45

The esbuild plugin intercepts TypeScript files via onTransform, runs them through Babel with the mangleErrorsPlugin, and returns the transformed code. This means Babel processes the code before esbuild — it's a preprocessing step, not a replacement for esbuild.

The Babel plugin itself lives in scripts/mangleErrors.mts:

scripts/mangleErrors.mts#L75-L185

flowchart TD
    A["Visit ThrowStatement nodes"] --> B{"Is it 'throw new Error(...)'?"}
    B -- No --> C["Skip"]
    B -- Yes --> D["Extract error message string<br/>via evalToString"]
    D --> E{"Already in errors.json?"}
    E -- Yes --> F["Use existing index"]
    E -- No --> G["Append to array,<br/>get new index"]
    G --> H["Set changeInArray = true"]
    F --> I{"minify mode?"}
    H --> I
    I -- Yes --> J["throw new Error(formatProdErrorMessage(N))"]
    I -- No --> K["throw new Error(<br/>process.env.NODE_ENV === 'production'<br/>? formatProdErrorMessage(N)<br/>: 'original message'<br/>)"]
    K --> L["Write updated errors.json<br/>in post() hook"]
    J --> L

The evalToString helper handles different AST representations of error messages — string literals, binary concatenation with +, and template literals — reducing them all to a plain string for lookup:

scripts/mangleErrors.mts#L27-L50

The error code registry is errors.json — a simple object mapping numeric indices to full messages:

errors.json#L1-L20

18 error codes. When the plugin runs with minify: false (Redux's current setting), each throw new Error("message") becomes:

throw new Error(
  process.env.NODE_ENV === 'production'
    ? formatProdErrorMessage(7)
    : "Actions must be plain objects..."
)

And formatProdErrorMessage generates a URL:

src/utils/formatProdErrorMessage.ts#L8-L13

In production, users see "Minified Redux error #7; visit https://redux.js.org/Errors?code=7 for the full message". The URL resolves to the full error text, including dynamic values. In development, they see the original verbose message.

Tip: The errors.json indices are stable across builds — new errors are appended, never re-indexed. This ensures that error code URLs remain valid across Redux versions. If you're building a library with a similar system, guard against index changes by loading the existing file (as done on line 83-86) before assigning new codes.

Dev/Prod Code Divergence Patterns

Error mangling is one form of dev/prod divergence. Redux uses several others.

The kindOf utility is the canonical example:

src/utils/kindOf.ts#L62-L70

In development, kindOf calls miniKindOf — a 38-line function that returns rich type descriptions like "date", "error", "WeakMap", "Promise", etc. In production, it falls through to plain typeof, returning basic types like "object", "function", "string".

flowchart TD
    A["kindOf(val)"] --> B{"NODE_ENV !== 'production'?"}
    B -- Dev --> C["miniKindOf(val)"]
    C --> D["'date', 'error', 'Map',<br/>'Promise', 'WeakSet', ..."]
    B -- Prod --> E["typeof val"]
    E --> F["'object', 'function',<br/>'string', ..."]

This means error messages in development say "Expected the root reducer to be a function. Instead, received: 'Promise'" while production says "...received: 'object'". The trade-off is clear: rich diagnostics in dev, minimal code in prod.

The warning utility uses a throw-and-catch trick for debugger integration:

src/utils/warning.ts#L6-L18

It logs to console.error, then throws and catches the same error. This seems pointless — but it exists so developers can enable "break on all exceptions" in browser DevTools and pause exactly where the warning originates. The caught error is immediately swallowed, so it doesn't affect execution.

Four Output Formats and Tree-Shaking

As we outlined in Article 1, Redux ships four formats. Here's why each exists and who consumes it:

Format File package.json field Consumer
ESM dist/redux.mjs exports["."].import Vite, webpack 5+, Rollup, Node.js w/ ESM
Legacy ESM dist/redux.legacy-esm.js module Webpack 4 (reads module, needs .js extension)
Browser ESM dist/redux.browser.mjs (not in exports) Direct <script type="module">
CJS dist/cjs/redux.cjs exports["."].default, main Node.js require(), Jest, older tooling

The sideEffects: false field in package.json (line 84) is the tree-shaking enabler. It tells bundlers that any export can be eliminated if unused. Without this, bundlers would have to assume that importing any module might have side effects (like registering a global), preventing dead code elimination.

flowchart TD
    A["import { compose } from 'redux'"] --> B["Bundler reads package.json"]
    B --> C["Selects redux.mjs via exports.import"]
    C --> D["Sees sideEffects: false"]
    D --> E["Tree-shakes everything except compose"]
    E --> F["Final bundle includes only<br/>compose (~16 lines)"]

The legacy ESM format targets ES2017 specifically (line 71 in tsup.config.ts) — this ensures Webpack 4, which doesn't handle modern syntax well, can process the output without additional transpilation.

Testing Built Artifacts

Redux's final quality gate is running the full test suite against the built output, not just the source:

vitest.config.mts#L1-L17

The TEST_DIST environment variable switches the redux import alias:

  • Without TEST_DIST: Tests import from src/index.ts — fast, direct, and useful during development
  • With TEST_DIST: Tests import from node_modules/redux — the built package, exactly as a consumer would see it

This catches a class of bugs that source-level testing misses:

  • Build transformations that alter behavior (like the error mangling)
  • Export map issues (a symbol exported from source but missing from the built entry point)
  • Format-specific problems (CJS interop quirks, missing .mjs extensions)
  • Tree-shaking breakage (esbuild eliminating something it shouldn't)

Tip: If you maintain a library, add a CI step that runs npm pack, installs the package in a temp directory, and runs tests against it. Redux's TEST_DIST approach achieves the same goal with less ceremony — it's worth stealing.

The Complete Build Pipeline

Stepping back, here's the full journey from source to consumer:

flowchart TD
    A["17 source files in src/"] --> B["tsup reads tsup.config.ts"]
    B --> C["For each of 4 build targets..."]
    C --> D["esbuild-extra intercepts .ts files"]
    D --> E["Babel mangleErrors plugin<br/>replaces throw messages<br/>with conditional expressions"]
    E --> F["esbuild compiles TS → JS"]
    F --> G{"Browser target?"}
    G -- Yes --> H["NODE_ENV hardcoded,<br/>minified"]
    G -- No --> I["NODE_ENV checks preserved"]
    H --> J["dist/redux.browser.mjs"]
    I --> K["dist/redux.mjs<br/>dist/redux.legacy-esm.js<br/>dist/cjs/redux.cjs"]
    K --> L["vitest with TEST_DIST<br/>validates built output"]
    J --> L

Series Recap

Over six articles, we've traced the entire Redux codebase:

  1. Architecture: 17 files, 9 exports, a deliberately minimal API surface
  2. createStore: Six closure variables, the dispatch cycle, dual-Map listener snapshotting
  3. combineReducers: Creation-time validation, the reference equality optimization, compose
  4. applyMiddleware: 25 lines, the mutable dispatch closure trick, the middleware pipeline
  5. Type System: Conditional inference, UnknownIfNonSpecific, composable StoreEnhancer generics, type-level tests
  6. Build Pipeline: Four output formats, error mangling, dev/prod code splitting, artifact testing

Redux's enduring influence comes not from its size — 1,575 lines is tiny — but from the precision of its abstractions. Closures instead of classes. Functions instead of configuration. Composition instead of inheritance. These are principles worth taking into any codebase.