Babel AST Transforms and When You Need It
The Tool That Made Modern JavaScript Possible
Back in 2014, ES6 (ES2015) was finalized but no browser supported it. Arrow functions, classes, template literals, destructuring — all this shiny new syntax, and you couldn't use any of it. Babel changed that. It let you write ES6+ today and ship ES5 that worked everywhere.
For nearly a decade, Babel was the foundation of the JavaScript build pipeline. Every React app, every Next.js project, every modern JavaScript application ran through Babel.
But here's the honest truth: in 2025, you probably don't need Babel anymore — unless you do. And knowing when you still need it is the point of this chapter.
Think of Babel as a language translator for JavaScript. You write in the latest dialect (ES2024+), and Babel translates it into an older dialect (ES5/ES2017) that all your target environments understand. It does this by reading your code into a structural representation (AST), applying translation rules (plugins), and writing the translated version back out. Now that most environments speak the latest dialect natively, the translator is less needed — but specialized translations (decorators, custom transforms, polyfills) still require it.
The Parse-Transform-Generate Pipeline
Babel's architecture is elegant in its simplicity. Every piece of code goes through exactly three phases:
Phase 1: Parse
@babel/parser (formerly Babylon) reads your source code string and produces an AST — a tree data structure representing the syntactic structure of your code.
// Source code
const greeting = "hello";
// Parsed AST (simplified)
{
type: "VariableDeclaration",
kind: "const",
declarations: [{
type: "VariableDeclarator",
id: { type: "Identifier", name: "greeting" },
init: { type: "StringLiteral", value: "hello" }
}]
}
Every piece of your code becomes a node in this tree. A function declaration, a variable, an if statement, a string literal — they're all AST nodes with a type and properties describing their structure.
Phase 2: Transform
Plugins traverse the AST using the visitor pattern. Each plugin declares which node types it's interested in, and Babel calls the plugin's handler when it encounters those nodes:
module.exports = function myPlugin() {
return {
visitor: {
StringLiteral(path) {
path.node.value = path.node.value.toUpperCase();
}
}
};
};
This plugin visits every StringLiteral node and uppercases its value. The path object provides the node plus methods to navigate, modify, and replace nodes in the tree.
Phase 3: Generate
@babel/generator walks the (now modified) AST and produces output source code with an optional source map.
AST Structure: The ESTree Standard
Babel's AST follows the ESTree specification (with Babel-specific extensions). Understanding the node types helps you read and write plugins.
Key node types you'll encounter:
// Variable: const x = 5;
VariableDeclaration → VariableDeclarator → Identifier + NumericLiteral
// Function: function greet(name) { return `Hi ${name}` }
FunctionDeclaration → Identifier + params:[Identifier] + BlockStatement
// Arrow: (x) => x * 2
ArrowFunctionExpression → params:[Identifier] + BinaryExpression
// Call: console.log("hi")
ExpressionStatement → CallExpression → MemberExpression + StringLiteral
// Import: import { useState } from 'react'
ImportDeclaration → ImportSpecifier(Identifier) + StringLiteral(source)
You can explore any code's AST interactively at astexplorer.net — paste code on the left, see the AST on the right. It's the essential tool for writing Babel plugins.
Presets vs Plugins
Plugins are individual transforms. Each one handles a specific syntax feature:
@babel/plugin-transform-arrow-functions— converts arrow functions to regular functions@babel/plugin-transform-optional-chaining— converts?.to conditional checks
Presets are collections of plugins bundled together:
@babel/preset-env— all standard ECMAScript transforms based on your target browsers@babel/preset-react— JSX transform + React-specific optimizations@babel/preset-typescript— strips TypeScript syntax
{
"presets": [
["@babel/preset-env", {
"targets": "> 0.5%, not dead",
"useBuiltIns": "usage",
"corejs": "3.37"
}],
["@babel/preset-react", {
"runtime": "automatic"
}],
"@babel/preset-typescript"
],
"plugins": [
["@babel/plugin-proposal-decorators", { "version": "2023-11" }]
]
}
@babel/preset-env and Browserslist
@babel/preset-env is Babel's smartest feature. Instead of blindly transforming everything to ES5, it checks your target browsers and only transforms the features those browsers don't support.
{
"targets": "> 0.5%, not dead"
}
Targeting modern browsers (Chrome 90+, Firefox 90+, Safari 15+)? preset-env barely transforms anything — those browsers support async/await, optional chaining, nullish coalescing natively. Targeting IE 11? Everything gets transformed.
The useBuiltIns: "usage" option is particularly clever: it analyzes your code, detects which features you actually use, and imports only the specific core-js polyfills needed. Use Array.prototype.at()? It imports the polyfill for .at(). Don't use Promise.allSettled()? That polyfill isn't included.
Writing a Simple Babel Plugin
Let's write a real plugin. This one removes console.log calls from production builds:
module.exports = function removeConsolePlugin() {
return {
visitor: {
CallExpression(path) {
const callee = path.get('callee');
if (
callee.isMemberExpression() &&
callee.get('object').isIdentifier({ name: 'console' }) &&
callee.get('property').isIdentifier({ name: 'log' })
) {
path.remove();
}
}
}
};
};
// Input
console.log('debug info');
const result = compute();
console.log('result:', result);
return result;
// Output (after plugin)
const result = compute();
return result;
The plugin visits every CallExpression node, checks if it's a console.log call by examining the callee's structure, and removes the entire expression statement if it matches.
A More Useful Plugin: Auto-Import
Here's a pattern used by libraries like babel-plugin-import (used by Ant Design) — automatically rewrite barrel imports to direct imports for better tree shaking:
module.exports = function autoDirectImport() {
return {
visitor: {
ImportDeclaration(path) {
const source = path.node.source.value;
if (source === 'my-ui-lib') {
const specifiers = path.node.specifiers;
const newImports = specifiers.map(spec => {
const name = spec.imported.name;
return {
type: 'ImportDeclaration',
specifiers: [spec],
source: { type: 'StringLiteral', value: `my-ui-lib/es/${name}` }
};
});
path.replaceWithMultiple(newImports);
}
}
}
};
};
// Input
import { Button, Modal, Table } from 'my-ui-lib';
// Output
import { Button } from 'my-ui-lib/es/Button';
import { Modal } from 'my-ui-lib/es/Modal';
import { Table } from 'my-ui-lib/es/Table';
When Babel Is Still Needed (2025)
Despite esbuild and SWC replacing Babel in most build pipelines, there are cases where Babel is still the right choice:
1. Custom AST Transforms
If you need project-specific code transforms (auto-importing, custom syntax extensions, compile-time macros), Babel's JavaScript-based plugin API is the most accessible. Writing a Babel plugin takes an afternoon. Writing an SWC WASM plugin in Rust takes much longer.
2. Polyfill Injection via @babel/preset-env
Neither esbuild nor SWC inject polyfills. Babel with @babel/preset-env and useBuiltIns: "usage" automatically detects which polyfills your code needs based on your browser targets. If you support older browsers that lack features like Array.prototype.at() or structuredClone(), Babel is still the most ergonomic solution.
3. Legacy Decorator Support
Some older codebases use the legacy decorator syntax (Babel's decorator proposal, not the TC39 stage 3 decorators). SWC supports this, but if your decorator usage is deeply intertwined with Babel-specific behavior, staying with Babel may be safer.
4. Ecosystem-Specific Plugins
Some libraries ship Babel plugins for compile-time optimization:
babel-plugin-styled-components— adds display names and SSR supportbabel-plugin-macros— compile-time code generation- Relay compiler integration
When esbuild/SWC Have Replaced Babel
For most modern projects, you no longer need Babel for:
- TypeScript compilation — both esbuild and SWC strip types (use
tscfor type checking) - JSX transformation — both handle JSX with React's automatic runtime
- ES2015+ syntax — modern browsers support everything. No transforms needed.
- Minification — esbuild's minifier replaces Terser (which replaced UglifyJS, which Babel used to feed)
If your project targets modern browsers, uses TypeScript, and doesn't need custom AST transforms, you likely don't need Babel at all. Next.js dropped Babel as the default compiler in version 12, replacing it with SWC.
| What developers do | What they should do |
|---|---|
| Adding Babel to a new project by default Babel adds build time overhead and configuration complexity. Modern tools handle TypeScript, JSX, and syntax transforms natively. Only add Babel when you have a specific need it uniquely solves. | Start without Babel. Add it only if you need features that esbuild/SWC can't handle (custom transforms, polyfill injection) |
| Using @babel/preset-env to transform everything to ES5 Transforming to ES5 produces larger, slower code. If 99% of your users are on modern browsers, you're degrading their experience for the 1%. Use browserslist data to set realistic targets. | Set appropriate browser targets that match your actual audience |
| Running Babel and TypeScript compiler (tsc) both for type stripping Running both doubles compilation time for no benefit. Use Babel/SWC/esbuild for fast compilation and run tsc separately only for type checking. | Use one tool for compilation and tsc --noEmit only for type checking |
| Writing a Babel plugin before checking if an SWC/esbuild alternative exists Native tool plugins run 10-100x faster. Only fall back to Babel plugins when no native alternative exists. | Check if the transform is available as an SWC plugin or esbuild plugin first |
- 1Babel's pipeline is parse (source to AST) then transform (plugins modify AST) then generate (AST to source + source map).
- 2@babel/preset-env only transforms features your target browsers don't support. Tight browser targets = fewer transforms = smaller, faster output.
- 3useBuiltIns: 'usage' with core-js automatically injects only the polyfills your code actually needs. This is still unique to Babel.
- 4Babel plugins use the visitor pattern — declare which AST node types you want to visit, and Babel calls your handler for each matching node.
- 5For new projects in 2025: start without Babel. Use SWC (Next.js) or esbuild (Vite) for compilation. Add Babel only for custom transforms or polyfill injection.
- 6astexplorer.net is the essential tool for writing and debugging Babel plugins.