Building and Bundling
Compiling versus bundling, emitting declaration files, source maps that make production stack traces readable, and choosing an output that suits who consumes it.
Compiling versus bundling, emitting declaration files, source maps that make production stack traces readable, and choosing an output that suits who consumes it.
A production error arrives:
TypeError: Cannot read properties of undefined
at t (/app/dist/index.js:1:48213)One line, column 48213, in a file that is entirely one line. The code that failed is somewhere in three hundred kilobytes of minified output, and nothing connects it back to what you wrote.
Building is where source becomes something you ship, and the decisions made there determine whether an incident like that takes five minutes or an afternoon. By the end of this lesson you will know what the compiler emits, what a bundler adds on top, and which settings decide whether a stack trace is usable.
One file in, one file out.
TypeScript to JavaScript, with your directory structure
preserved. That is all tsc does.
For a Node library or a small service it is often all you need.
Many files in, a few files out.
Resolution, minification, tree-shaking and asset handling on top of the compile.
Required for the browser, where hundreds of separate module requests would be unusable.
Most modern build tools do not type-check. esbuild, swc
and Vite strip types and emit, exactly as tsx did in the
foundations course:
{
"scripts": {
"check": "tsc --noEmit",
"build": "vite build"
}
}Both in CI. A successful build says nothing about type correctness, which surprises people the first time a broken type reaches production through a green pipeline.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"declaration": true,
"declarationMap": true
}
}target decides how much your code is rewritten. A lower
target means older syntax and more transformation — async
functions become state machines, class fields become
constructor assignments. Set it to the oldest runtime you
support and no lower, because every step down produces larger,
slower output.
declaration emits .d.ts files. Required for a library;
pointless for an application.
declarationMap links those back to your source, so a
consumer's go-to-definition lands in your actual code rather
than a declaration file.
Bad — shipping without them.
Good — generated, and available to whatever reads the trace.
A source map records which output position came from which source position. Without one, a minified trace names a single-character function at a column number, and finding the real line means reverse-engineering the bundle.
For a Node service, sourceMap: true plus
--enable-source-maps is the whole answer:
For the browser, do not publish maps publicly — they contain your full source. Upload them to your error tracker instead, which is a standard step in every such tool:
This is the highest-value line in this lesson. It costs a build flag and converts an unreadable trace into a file and a line number.
Different from an application in three ways.
Do not bundle dependencies. A library that inlines its dependencies means a consumer gets two copies of whatever else they already had. Mark them external:
Do not minify. Consumers minify, and unminified code makes their debugging possible.
Ship types, and both formats if you must. The exports map
from the modules lesson, with types first in each block:
files limits what is published. sideEffects: false tells
bundlers that importing a module does nothing on its own, which
is what makes tree-shaking possible for your consumers — and is
a lie if any module runs code at import time.
Dual-format publishing is worth avoiding when you can. It doubles your build and creates the dual-package hazard from the modules lesson, where one process loads both copies.
Three things that actually matter for a browser bundle.
Tree-shaking removes exports nobody imports. It requires ESM
— a bundler cannot statically analyse require — and it is
defeated by side effects at module top level, which is what
sideEffects: false is asserting.
Code splitting loads what a page needs, when it needs it:
A dynamic import() becomes a separate chunk. A charting
library nobody opens should not be in the initial download.
Measure before optimising, which is the profiling lesson applied here:
The result is nearly always one dependency far larger than expected — a date library where three functions were needed, an icon set imported whole. Guessing which one wastes the afternoon; the visualiser names it in seconds.
Two habits, both cheap.
Install from the lock file. npm ci fails if
package-lock.json and package.json disagree, and installs
exactly what is locked. npm install may resolve something
newer, which means the artefact you tested is not the one you
ship.
Build once, deploy that. Rebuilding per environment gives each one a different artefact from the same commit. Build in CI, store the output, and promote the same files through staging to production.
Environment differences then belong in configuration, read at runtime — which is the validated-at-startup pattern from the foundations course rather than a build-time substitution.
You can now produce output suited to what consumes it, and — the
part that matters at 3am — connect a production stack trace back
to the line you wrote. Source maps plus --enable-source-maps
is a two-line change with a return that is hard to overstate.
Next is Runtime Versus Compile Time, which makes explicit something this course has circled repeatedly. Types are erased before the program runs, and knowing exactly what survives explains a whole family of surprises — including several you have already met.
Before you move on, check whether your production build emits source maps and whether whatever reads your errors can use them. In a surprising number of projects the answer is that maps are generated and nothing consumes them, which is all of the cost and none of the benefit.
TypeError: Cannot read properties of undefined
at t (/app/dist/index.js:1:48213)TypeError: Cannot read properties of undefined
at loadPhoto (src/photos/storage.ts:42:18)"sourceMap": true for a service you control
hidden-source-map for the browser: generated,
no comment in the output
upload to the error tracker never served alongside the appTWO JOBS
compiling .ts -> .js, one file each; tsc does this
bundling many -> few, plus resolution, minify, tree-shake
a bundler is required for the browser, optional for Node
esbuild/swc/vite DO NOT TYPE-CHECK - they strip and emit
"check": "tsc --noEmit" AND "build": ... , both in CI
a green build says nothing about types
SETTINGS
target the oldest runtime you support, no lower -
each step down means more rewriting, bigger output
declaration .d.ts - for a library
declarationMap consumers' go-to-definition lands in your source
SOURCE MAPS - the highest-value line here
without: at t (dist/index.js:1:48213)
with: at loadPhoto (src/photos/storage.ts:42:18)
node --enable-source-maps dist/index.js
browser: hidden-source-map, uploaded to the error tracker,
NEVER served publicly - they contain your source
A LIBRARY
do not bundle dependencies - mark them external
do not minify - consumers do that, and need to debug you
exports map with "types" FIRST in each block
"files": ["dist"] what gets published
"sideEffects": false enables consumer tree-shaking -
and is a LIE if a module runs code on import
avoid dual ESM+CJS when you can: doubles the build, and one
process can load both copies
SMALLER OUTPUT
tree-shaking needs ESM; defeated by top-level side effects
code splitting await import("./charts.js") -> its own chunk
measure first vite-bundle-visualizer / source-map-explorer
it is almost always ONE oversized dependency
an enum emits real code and cannot be shaken out;
an `as const` union emits nothing
REPRODUCIBLE
npm ci, not npm install
build ONCE, promote the same artefact; config at runtime{ "sourceMap": false }{ "sourceMap": true }node --enable-source-maps dist/index.jssentry-cli sourcemaps upload ./dist// tsup.config.ts
export default {
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
external: ["react", "zod"],
};{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": ["dist"],
"sideEffects": false
}const { renderChart } = await import("./charts.js");npx vite-bundle-visualizer
npx source-map-explorer dist/*.js