Transpilation with Babel
|
This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve. This section’s bibliography lists the reference material consulted while preparing these pages. |
Browsers and JavaScript runtimes adopt new ECMAScript syntax at different speeds, and a codebase that targets a
broad audience — older mobile browsers, a corporate intranet stuck on an old Chromium build, a long-tail of
devices that will never see an OS update — cannot simply write the newest syntax and assume every environment
understands it. Babel is a JavaScript compiler that solves this by transpiling: it parses source code written
in modern (or experimental) JavaScript syntax and re-emits equivalent code using only the syntax a chosen set of
target environments already supports. This page covers how Babel is configured (@babel/preset-env and
browserslist), what a transpilation actually looks like before and after, how source maps keep transpiled code
debuggable, and the distinction between transpilation and polyfilling — two different problems that are commonly
conflated.
What Transpilation Solves
"New JavaScript" arrives in two flavors, and Babel addresses only one of them:
-
New syntax — optional chaining (
?.), nullish coalescing (??), class fields,async/await, arrow functions, destructuring, and so on. These are new ways of writing code; the underlying capability they express (a conditional property lookup, an asynchronous control-flow shape) could always be expressed with older syntax too, just more verbosely. A JavaScript engine that has never heard of?.cannot parse a file containing it at all — the whole script fails with aSyntaxErrorbefore a single line runs. -
New runtime APIs —
Array.prototype.flat(),Promise.allSettled(),Object.fromEntries(),structuredClone(). These are new values the language adds to objects that already exist at runtime. A syntactically valid script that callsarr.flat()on an engine without it parses and runs fine right up until that line executes, then throwsTypeError: arr.flat is not a function.
Babel’s job is the first category: it rewrites syntax the target engine cannot parse into equivalent syntax it can. It does this by parsing the source into an abstract syntax tree (AST), applying a pipeline of transform plugins to that tree, and generating new source text from the transformed tree — the same parse-transform-generate pipeline any compiler uses, except both the input and output are JavaScript. Babel has no opinion on the second category at all; that is the polyfill’s job, covered in its own section below.
Babel does the same parse/transform work for JSX (@babel/preset-react, in projects that use it) and TypeScript
type-stripping, but this page focuses on Babel’s core use case: modern ECMAScript syntax down to whatever a
project’s browserslist targets require.
The Plugin and Preset Model
At its core, Babel is a small parsing/generation engine plus a plugin system — almost all of the actual
transformation logic lives in individually installable plugins, each handling one syntax feature (e.g.
@babel/plugin-transform-optional-chaining, @babel/plugin-transform-class-properties). Configuring a project by
hand-picking dozens of individual plugins is impractical, so Babel ships presets — curated bundles of plugins
for a common goal.
@babel/preset-env: targets-driven transformation
@babel/preset-env is the preset almost every project uses for "compile modern JavaScript down to whatever my
targets need." Rather than transforming every feature down to some fixed baseline (which would bloat output
with transforms nobody needs), it is a smart preset: it reads a list of target environments, cross-references
each candidate ECMAScript feature against compatibility data (drawn from sources like compat-table and
caniuse), and includes only the plugins whose feature the targets don’t already support natively. Targeting
"last 2 Chrome versions" transforms almost nothing; targeting "IE 11" transforms nearly everything down to ES5.
Targets are supplied via browserslist — a shared configuration format also consumed by Autoprefixer, ESLint,
and most bundler toolchains, so a project’s "which environments do I support" answer lives in one place instead
of being repeated per tool. @babel/preset-env reads it automatically, either from a .browserslistrc file at
the project root:
# .browserslistrc
> 0.5%
last 2 versions
Firefox ESR
not dead
or from a browserslist key in package.json:
{
"name": "my-app",
"browserslist": [
"> 0.5%",
"last 2 versions",
"Firefox ESR",
"not dead"
]
}
Each line is a query — "greater than 0.5% global usage share," "not abandoned by its maker" (not dead) — and
browserslist resolves the combined query set against real usage data to a concrete list of browser/version
pairs, which @babel/preset-env then treats as its transformation floor. A project can also pass targets
directly in Babel’s own config instead of (or to override) a browserslist file, which is useful for a
Node.js-only tool that wants to target a specific Node version rather than browsers:
{
"presets": [
["@babel/preset-env", { "targets": { "node": "current" } }]
]
}
This configuration lives in babel.config.json (project-wide, the recommended root config for anything beyond a
single package) or .babelrc.json (resolved per-package, by walking up from the file being compiled until a
package.json is found) — both share the same JSON shape of presets and plugins arrays. A minimal
project-wide config is just:
{
"presets": ["@babel/preset-env"]
}
@babel/preset-react: JSX, briefly
Projects that write JSX — React’s HTML-like syntax embedded in JavaScript — add @babel/preset-react
alongside @babel/preset-env. JSX is not valid JavaScript syntax at all (no engine, however modern, can parse
<div>{name}</div> directly), so this isn’t a "downlevel old syntax" transform in the preset-env sense; it is
a one-way conversion of JSX expressions into plain React.createElement(…) calls (or, with the modern
automatic runtime, calls into react/jsx-runtime) that every engine can already run:
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
The two presets are complementary and commonly used together: preset-react strips JSX down to plain function
calls, and preset-env then downlevels whatever ECMAScript syntax remains (including inside those calls) to the
project’s targets.
A Worked Example: ES2022+ Down to ES5
The snippet below uses several features that are still absent from some deployed engines: optional chaining
(?.), nullish coalescing (??), a public class field declaration, and a static class field. Written the
way a developer would naturally write it today:
// before -- modern ES2022 syntax
class UserCard {
retries = 0; // public class field
static maxRetries = 3; // static class field
render(user) {
const city = user?.address?.city ?? "Unknown";
const name = user?.profile?.displayName ?? user?.username ?? "Guest";
return `${name} (${city})`;
}
}
Run through @babel/preset-env with a browserslist target of "> 0.25%, not dead, ie 11", the output looks
roughly like this (simplified for readability — the real output also inlines small _defineProperty-style
runtime helpers Babel generates, rather than each engine feature mapping to a single obvious line):
// after -- ES5-compatible output
var UserCard = /*#__PURE__*/function () {
function UserCard() {
_classCallCheck(this, UserCard);
this.retries = 0; // class field -> constructor assignment
}
_createClass(UserCard, [{
key: "render",
value: function render(user) {
var _user$address, _user$profile, _ref, _user$username;
var city =
(user === null || user === void 0
? void 0
: (_user$address = user.address) === null || _user$address === void 0
? void 0
: _user$address.city) !== null && /* ...continues ?? fallback... */ true
? /* left side */ undefined
: "Unknown";
// (optional chaining + nullish coalescing expand into explicit null/undefined checks)
var name =
(_ref =
user === null || user === void 0
? void 0
: (_user$profile = user.profile) === null || _user$profile === void 0
? void 0
: _user$profile.displayName) !== null && _ref !== void 0
? _ref
: (user === null || user === void 0
? void 0
: (_user$username = user.username)) !== null &&
(user === null || user === void 0 ? void 0 : _user$username) !== void 0
? user.username
: "Guest";
return "".concat(name, " (").concat(city, ")"); // template literal -> String.concat
}
}]);
return UserCard;
}();
UserCard.maxRetries = 3; // static field -> assignment after the class
The example above is intentionally verbose to make a point: expanding ?. and ?? correctly (short-circuiting
on both null and undefined, evaluating each intermediate step only once) takes many more ES5 tokens than the
one-line modern original, and a class field becomes an ordinary constructor assignment because ES5 has no field
syntax at all. Real Babel output also renames variables to avoid collisions and, for a target as old as IE 11,
adds small runtime helper functions (_classCallCheck, _createClass) inline unless
@babel/plugin-transform-runtime is configured to import them from @babel/runtime instead, which keeps output
smaller across many files by sharing one copy of each helper rather than duplicating it per file. Targeting a
more modern floor (e.g. just "last 2 Chrome versions," which already understands ?., ??, and class fields)
would leave the snippet almost entirely untouched — this is the "only transform what your targets need" behavior
described above.
Source Maps for Debugging
Transpiled output is not code a developer wants to read, step through, or see in a stack trace — variable names are mangled, control flow is restructured, and a single original line can expand into a dozen. A source map is a JSON side-file (or an inline, base64-encoded data URL appended to the generated file) that records a mapping from every position in the generated file back to the corresponding position in the original source. Babel generates one automatically when asked:
npx babel src --out-dir lib --source-maps
which produces lib/userCard.js alongside lib/userCard.js.map, and appends a comment to the generated file
pointing browsers/tools at it:
//# sourceMappingURL=userCard.js.map
Browser devtools (Chrome DevTools, Firefox Developer Tools) read that comment, fetch the map, and use it
transparently: breakpoints set in the Sources panel, stack traces in the console, and the "pretty" file shown
in the debugger all display the original userCard.js — with its real variable names and un-expanded
?./?? expressions — even though the file actually executing is the ES5 output above. This is what makes it
practical to debug a transpiled (and typically also bundled/minified — see
Bundling & npm Publishing) production build at all: the
mapping lets "Chrome says the error is at line 47" resolve back to the line the developer actually wrote, rather
than an unreadable line inside a generated helper function.
A source map is only as useful as the chain that produces it staying intact: a bundler that concatenates Babel’s output with other files needs to combine the incoming source maps with its own (most bundler integrations for Babel do this automatically), and a minifier run after Babel needs to consume Babel’s map and emit an updated one rather than discarding it — otherwise devtools fall back to showing the final, unreadable generated code instead of the original source.
Babel Is Not a Polyfill: Two Different Problems
This is the single most common point of confusion around Babel, so it is worth stating plainly: Babel
transforms syntax; it does not add missing runtime behavior. The worked example above shows this clearly — ?. and ?? are pure syntax, and Babel’s expanded null/undefined checks reproduce their behavior using only
operators and control flow the target engine already has. There was never a missing function to supply.
Contrast that with a call like [1, [2, 3]].flat(). flat() is not new syntax at all — arr.flat() is an
ordinary method call, indistinguishable syntactically from any other method call a parser has understood for
decades. What is missing on an old engine is the method itself: Array.prototype.flat simply does not exist
on that engine’s built-in Array object. No amount of syntax transformation fixes this, because there is no
syntax to transform — the AST node for arr.flat() is identical whether the target engine has the method or
not. This is what a polyfill is for: a small piece of ordinary JavaScript that, loaded before the rest of the
application, checks whether a given built-in is missing and defines it if so, so that application code can call
arr.flat(), Promise.allSettled(), or Object.fromEntries() and have it simply work.
| Babel (syntax transformation) | Polyfill (runtime library, e.g. core-js) |
|
|---|---|---|
Problem it solves |
The target engine cannot parse the source at all (new operators, new declaration forms, new literal syntax). |
The target engine parses and runs the source fine, but is missing a built-in value (method, global, prototype member) the code calls at runtime. |
Example feature |
Optional chaining ( |
|
How it’s applied |
Ahead of time, as a build step — rewrites the source text itself before it ships. |
At runtime, as a small library loaded (once) before application code — adds properties to existing built-ins. |
Failure mode if skipped |
|
|
In practice the two are used together, and @babel/preset-env offers a convenience bridge between them through
its useBuiltIns option, which does not perform polyfilling itself but controls how polyfill imports from the
core-js library get injected into the build:
-
useBuiltIns: false(the default) — Babel only transforms syntax; polyfills, if any, must be imported manually (e.g.import "core-js/stable";once, near the application’s entry point). -
useBuiltIns: "entry"— a singleimport "core-js";(or"core-js/stable") at the entry point is expanded into the specific per-feature polyfill imports the configured targets actually need, based on the samebrowserslisttargetspreset-envalready uses for syntax. -
useBuiltIns: "usage"— Babel inspects each file for which built-ins it actually uses and inserts only those polyfill imports automatically, file by file, without requiring a manual entry-point import at all. This gives the smallest output but requirescore-jsto be an explicit project dependency (Babel imports from it, it doesn’t vendor it).
{
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": "3.33"
}]
]
}
The corejs option pins which core-js major/minor version’s polyfill set to draw from, and must match the
core-js version actually installed. Whichever useBuiltIns mode is chosen, the underlying rule stays the same:
Babel’s browserslist targets decide both which syntax gets left alone (because the target already supports
it) and which polyfills get pulled in (because the target is missing the built-in) — but they remain two
separate outputs of that one targets list, not one mechanism.
Where Babel Fits in the Toolchain
Babel is a compiler, not a bundler — it turns one modern JavaScript file into one ES5-compatible file (with an
associated source map), but has no notion of module graphs, code splitting, or writing files to disk in a
deployable layout. In a real project, Babel almost always runs inside a bundler’s build pipeline rather than as
a standalone step — e.g. as a webpack loader (babel-loader) or through a bundler’s built-in transform
integration — so that authored source flows through "resolve imports → transpile each module → bundle → minify"
in a single pass. See Bundling & npm Publishing for how
that pipeline fits together, how the resulting bundle gets published, and how source maps survive the subsequent
bundling and minification steps described above.