Transpilers & the Compile Pipeline: Babel, SWC, esbuild & tsc

You write user?.profile?.name, ship it, and a support ticket comes in: a blank page on someone’s older tablet. The syntax you used is a few years newer than the browser on that device. The code is correct — the engine just doesn’t recognize the grammar.

A transpiler is the tool that closes that gap. It reads the modern (or TypeScript, or JSX) source you actually wrote and rewrites it into a form the target engine understands, without changing what the code means. “Transpile” is short for source-to-source compile: JavaScript in, different JavaScript out, same behavior. That’s a narrower job than people usually assume, and getting the boundaries right is most of understanding the modern toolchain.

If you’ve read Build Tools: Vite, esbuild & Rolldown, you’ve already seen these tools mentioned as one stage inside a bundler. This zooms into that stage: what transpiling actually is, where it stops, and which tool to reach for.

Three jobs people mash into one word

“Build step” gets used as a single blob, but there are three genuinely separate jobs inside it. They run at different times, for different reasons, and you can do any one without the others.

  • Transpiling — rewrite syntax. Down-level newer grammar to older grammar, or strip TypeScript’s type annotations to get plain JS. One file in, one file out. Behavior unchanged.
  • Bundling — collapse many modules into few files. Follow every import, stitch the graph together. This is about file count and network requests, covered in Build Tools.
  • Minifyingshrink the output. Rename locals, drop whitespace and comments, fold constants. Smaller bytes, identical behavior.
transpilea ?? bconst x: Ta != nullconst xsame behavior,older grammarbundleone filefewer requestsminifyfunction total(items)// sum the cartfunction t(e){…}fewer bytes
Three distinct transformations. Transpiling changes the shape of the syntax; bundling changes how many files there are; minifying changes the size. A tool may do one, two, or all three.

This article is about the first job. But the tools blur the lines — esbuild transpiles and bundles and minifies — so knowing which hat a tool is wearing at any moment keeps you sane when a config option doesn’t do what you expected.

The pipeline: source → AST → source

Every transpiler, no matter the language it’s written in, runs the same three-stage pipeline. Source text is a flat string of characters; you can’t reliably transform code by find-and-replace on a string (a variable named class inside a comment would wreck you). So the tool turns the text into a structured tree first, edits the tree, then prints the tree back to text.

1 · sourceconst n =a ?? 0;parse2 · ASTVariableDeclNullish( a, 0 )a0transform3 · new ASTVariableDeclConditionala != null ? a : 0print4 · outputconst n =a != null? a : 0;
The universal transpiler pipeline. Parsing turns text into a tree, transforms rewrite the tree, and code generation prints a new string. Every tool here — Babel, SWC, esbuild, tsc — is a variation on this filmstrip.

Parse: text becomes a tree

The parser reads characters and produces an Abstract Syntax Tree — a nested object graph where every construct is a typed node. const n = a ?? 0 becomes a VariableDeclaration containing a declarator whose initializer is a LogicalExpression with operator ?? and two operands. The tree is “abstract” because it drops things that don’t affect meaning: whitespace, the exact number of parentheses, comment placement (kept as metadata, not structure).

Most JS tools speak a shared dialect of AST called ESTree, which is why a plugin ecosystem can exist across parsers. You can see a real tree for any snippet at astexplorer.net — paste code, watch the tree update. It’s the single best way to build intuition here.

Transform: rewrite the tree

Now the interesting part. A transform walks the tree and rewrites nodes. Down-leveling ?? means: find every LogicalExpression with operator ??, and replace it with a ConditionalExpression that checks != null. Stripping TypeScript means: find every type annotation node and delete it. JSX becomes a React.createElement (or jsx()) call. Each of these is one tree-to-tree rewrite, and they compose — a single pass can apply dozens.

This is where Babel’s plugins and presets live: a preset is a bundle of transforms, and each transform is a visitor that matches certain node types and mutates them.

Generate: tree becomes text

Finally the code generator (also called the printer or emitter) walks the transformed tree and prints valid source text, making decisions about indentation, quote style, and semicolons. If asked, it also produces a source map alongside — the record of which output position came from which input position. More on that below, because it’s what makes the whole thing debuggable.

Down-leveling: targets and browserslist

The reason a transpiler rewrites ?? at all is a target: the oldest engine you’ve promised to support. If your target already understands ?? (every current browser does), rewriting it is pure waste — bigger, slower output for no benefit. So you tell the tool your target and it down-levels only what that target lacks.

sourceconst f = x =>x?.value ?? 0;target: modern (es2022)const f = x => x?.value ?? 0; ← unchangedtarget: es2017const f = x => x != null ? x.value : void 0 ?? 0;target: es5var f = function (x) { return x != null ? x.value : void 0; }; ← arrow gone too
Down-leveling is target-driven. The same source produces different output depending on how old an engine you must support. A modern target may pass the syntax through untouched.

For browser targets you rarely hand-write a version. Instead you declare a browserslist query — a human-readable description of your support matrix that Babel, SWC, esbuild’s ecosystem, autoprefixer, and others all read. It lives in package.json or a .browserslistrc:

{
  "browserslist": [
    "> 0.5%",
    "last 2 versions",
    "not dead"
  ]
}

That resolves, against a regularly-updated dataset of real usage, to a concrete list of browser versions. The tool intersects your code’s features with that list and down-levels only the gaps. Check what a query actually expands to:

npx browserslist "> 0.5%, last 2 versions, not dead"

The distinction that trips everyone: syntax vs API

Here is the single most important idea in this whole article, and the one that produces the most confusing production bugs.

A transpiler rewrites syntax. It cannot invent a missing runtime API.

Syntax is grammar — arrow functions, ??, optional chaining, class, destructuring, async/await. These are shapes the parser recognizes, and a transform can always rewrite them into older shapes that do the same thing. That’s transpiling.

An API is a thing that exists at runtimeArray.prototype.flat, Object.fromEntries, Promise.any, structuredClone, String.prototype.replaceAll. These aren’t grammar; they’re methods and globals the engine either has or doesn’t. A transpiler can’t rewrite arr.flat() into something older, because .flat is just a method call — syntactically it’s identical to arr.anything(). If the engine’s array doesn’t have flat, the call throws TypeError: arr.flat is not a function at runtime, no matter how you transpile it.

To fill a missing API you need a polyfill: actual code that installs the missing method onto the prototype before your code runs.

SYNTAX → transpilegrammar the parser seesa?.b x => x const {p} = oa && a.b function(x){…}rewrite always possibleAPI → polyfilla method that must exist at runtimearr.flat() Object.fromEntries()if(!Array.prototype.flat) Array.prototype.flat = function(){…}no rewrite can help — ship the code
Syntax is grammar a transform can rewrite. An API is a runtime capability that either exists or doesn't — no rewrite conjures it, so a missing one needs a polyfill shipped alongside your code.

The standard polyfill library is core-js (3.49.0 as of March 2026), a big collection of spec-accurate implementations for everything from Promise to the latest iterator helpers. You don’t hand-pick them: Babel’s preset-env with useBuiltIns: "usage" reads your browserslist, scans which APIs your code touches, and injects only the core-js imports your targets are missing.

// babel.config.js
export default {
  presets: [
    ["@babel/preset-env", {
      useBuiltIns: "usage",   // inject core-js per API actually used
      corejs: "3.49",
    }],
  ],
};

The tools, and what each is actually for

They look interchangeable from the outside — “the thing that turns my TS into JS” — but they were built for different priorities. Written in JavaScript, Rust, or Go; optimized for extensibility, speed, or type-safety.

speed → fastertype-aware →tsc / tsgochecks types + emitsBabelplugins, polyfills, JS-basedSWC (Rust)strips types, no checkesbuild (Go)strips types, no checkunderstands typessyntax only
The four transpilers on two axes: how fast they run, and whether they understand types well enough to report type errors. Speed and type-checking are separate concerns — the fast tools deliberately skip checking.

Babel — the extensible classic

Babel is the original, written in JavaScript, and still the most flexible. Its whole model is plugins and presets: @babel/preset-env handles down-leveling driven by your browserslist, @babel/preset-typescript strips types, @babel/preset-react handles JSX. If you need a bespoke transform — a macro, a custom syntax proposal, code instrumentation — Babel’s plugin API is where you write it. Babel 8.0 shipped in June 2026: it’s now ESM-only, requires Node 22+, and no longer targets ES5 by default. It’s slower than the Rust and Go tools because it runs in JS, but nothing matches its ecosystem for custom transforms and precise polyfill injection.

SWC — Babel’s job, in Rust

SWC (“Speedy Web Compiler”) is a Rust reimplementation of the same job — transpile, strip types, minify — running roughly an order of magnitude faster. It’s the compiler inside Next.js and a pile of other tooling, and it’s what powers Node’s built-in type stripping (via the amaro wrapper). It reads a .swcrc and covers the common cases a typical app needs. It does not type-check; it strips types by erasing them.

esbuild — Go, and absurdly fast

esbuild is written in Go, uses real OS threads for parallelism, and is fast enough that it changed people’s expectations of what a build should cost. It transpiles, bundles, and minifies in one pass. It’s the transform engine under many dev servers. Notably it’s still pre-1.0 (0.28.x in mid-2026) — stable in practice and everywhere in production, but the version number honestly signals that its API can still shift. Like SWC, it strips types without checking them and adds no polyfills.

tsc — the one that actually checks types

tsc is the TypeScript compiler, and it’s doing a fundamentally different job from the other three. The fast tools erase types — they trust that x: number is correct and throw the annotation away. tsc verifies it. It’s the only tool here that will tell you that you passed a string where a number was required. It can also emit JS, but in modern setups its emit is often turned off and its sole job is type-checking, while a faster tool does the actual transform.

The big 2026 story: TypeScript 7.0 — a native rewrite of the compiler in Go, roughly 10x faster than the JavaScript-based 5.x line — reached Release Candidate in June 2026, with a stable release expected shortly after. Same type-checking behavior, dramatically less waiting. Until you’ve moved, the 5.x tsc is still what most projects run.

Running TypeScript without a build step

Sometimes you don’t want a build at all — you just want to run a .ts file. Three ways, in rough order of how the ecosystem moved:

  • tsx and ts-node — wrappers that transpile on the fly (tsx uses esbuild under the hood) so node-style execution works on TS files. tsx is the fast, low-config default most people reach for now.
  • Node’s built-in type stripping — as of Node 24 (the 2026 LTS), Node runs .ts files directly by erasing types, no flag and no dependency. It’s powered by SWC via amaro. The catch: it only handles erasable syntax. Features that emit runtime code — enum, namespace with values, constructor parameter properties — aren’t stripped and need either a real compiler or the --experimental-transform-types flag.
  • Deno and Bun — both execute TypeScript natively as a first-class input, no setup.
# transpile-and-run with tsx
npx tsx server.ts

# Node 24+ runs erasable TS directly, no flag
node server.ts

Source maps: getting your real code back

Down-level, bundle, and minify your code and the file the browser runs looks nothing like what you wrote — one line named t, e, n. So when it throws, the stack trace points at column 4,812 of index-a3f9.js, which tells you nothing. A source map fixes this. It’s a JSON file the generator emits alongside the output that records, for every position in the compiled file, which original file/line/column it came from.

compiled: app-a3f9.js1 {function t(e){return e!=null?e.v :void 0.x} ← throws hereTypeError: cannot read .xat t (app-a3f9.js:1:118).maporiginal: cart.ts7 function total(item) {8 return item?.value ?? 0;9 }stack now reads: cart.ts:8:12the line you actually wrote
A source map is a lookup table from a position in the compiled output back to the original source position. DevTools and error reporters read it, so a crash in minified code still points at the line you actually wrote.

The compiled file ends with a comment linking the map:

//# sourceMappingURL=app-a3f9.js.map

DevTools loads that map automatically and shows you cart.ts, not the mangled output. The mapping itself is a compact base64 VLQ encoding of position deltas, which is why a .map file looks like line noise — you never read it by hand.

Try it: a tiny transpiler

Down-leveling optional chaining is small enough to do by hand, which makes it a good way to feel what a transform does. Type an expression using ?., and this converts it to an equivalent && chain — the classic pre-2020 pattern. It’s a deliberately simple, honest transform (single expression, no assignment, no calls), so you can read exactly what it does.

interactiveOptional chaining, down-leveled by hand

The real thing differs in one honest way: a?.b short-circuits on null and undefined only, whereas naive a && a.b also short-circuits on 0, "", and false. That’s exactly why Babel emits the more careful a != null ? ... form instead of && — a good reminder that “obvious” hand-rewrites can quietly change behavior, and why doing it on a real AST with the real semantics matters.

Do you even need a transpiler in 2026?

Honest answer: less than you used to, and for some projects, not at all.

Modern browsers and Node ship native ES modules, ??, optional chaining, top-level await, and years of syntax that once required transpiling. If you write plain modern JavaScript and target current evergreen browsers, the browser runs your source almost verbatim — an import map can even resolve bare specifiers with no build. Node 24 runs TypeScript directly by stripping types. The default assumption “of course there’s a build step” is no longer automatic.

You still reach for a transpiler when:

  • You use TypeScript or JSX — these are non-standard syntax an engine won’t run as-is (type stripping covers the erasable subset, but JSX and non-erasable TS still need a transform).
  • You must support older browsers that lack recent syntax, or need polyfills for missing runtime APIs.
  • You’re bundling and minifying for production anyway — the transpile step comes along for free inside the bundler, so there’s no reason to skip it.

The shift is that transpiling went from mandatory ritual to deliberate choice. You should be able to say which of the reasons above applies to your project. If none do, dropping the build step is a legitimate, often excellent, decision.

Summary

  • A transpiler is a source-to-source compiler: same behavior, different syntax. It’s one job, distinct from bundling (many files → few) and minifying (shrink bytes).
  • Every transpiler runs the same pipeline: parse text into an AST, transform the tree, generate new text — plus an optional source map.
  • Down-leveling is driven by a target, usually expressed as a browserslist query. Aim as high as your real audience allows; older targets cost bytes.
  • The critical split: a transpiler rewrites syntax but cannot invent a missing runtime API. Missing APIs need a polyfill (core-js). esbuild and SWC don’t auto-polyfill; Babel’s preset-env does.
  • Babel (JS, maximally extensible, v8 as of June 2026), SWC (Rust, fast, strips types), esbuild (Go, fastest, still pre-1.0) all transform without checking types. tsc is the one that actually type-checks — and its Go-native 7.0 rewrite hit RC in mid-2026.
  • A common setup runs a fast transpiler for the build and tsc --noEmit for type-checking in CI. See TypeScript Tooling and Build Tools.
  • Source maps map compiled positions back to your original source so crashes and debugging point at code you actually wrote. Ship them, but keep them private.
  • Native ESM and Node’s type stripping mean a transpiler is now a deliberate choice, not a default — reach for one when you use TS/JSX, support old engines, or bundle for production anyway.