From Source to Bundle: Error Mangling, Multi-Format Builds, and Dev/Prod Divergence
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:
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:
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:
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.jsonindices 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:
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:
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:
The TEST_DIST environment variable switches the redux import alias:
- Without
TEST_DIST: Tests import fromsrc/index.ts— fast, direct, and useful during development - With
TEST_DIST: Tests import fromnode_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
.mjsextensions) - 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'sTEST_DISTapproach 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:
- Architecture: 17 files, 9 exports, a deliberately minimal API surface
- createStore: Six closure variables, the dispatch cycle, dual-Map listener snapshotting
- combineReducers: Creation-time validation, the reference equality optimization,
compose - applyMiddleware: 25 lines, the mutable dispatch closure trick, the middleware pipeline
- Type System: Conditional inference,
UnknownIfNonSpecific, composableStoreEnhancergenerics, type-level tests - 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.