Codemods & Babel plugins
Write Babel plugins and jscodeshift codemods that change code across a whole codebase, keep its formatting with recast, and test every transform.
- 01Write a Babel pluginUse a visitor, paths,
typesand options to change code on every build. - 02Write a codemodUse jscodeshift to change source files once, keeping their formatting with recast.
- 03Test and run it safelyCheck transforms with fixtures and a second run, then dry-run them before writing files.
What a codemod is
A codemod is a program that changes code for you, across a whole codebase. When a library renames a function, you can run one codemod instead of editing 400 files by hand. Next.js, for example, ships its upgrade codemods as npx @next/codemod.
Codemods use the steps from the working with ASTs lesson: parse, find, change, print. This lesson uses the real tools: Babel plugins, jscodeshift and recast.
import jscodeshift from "jscodeshift";
const j = jscodeshift;
const source = 'const user = fetchUser(7); // the admin\nshow(fetchUser, "fetchUser");';
const output = j(source)
.find(j.Identifier, { name: "fetchUser" })
.replaceWith(() => j.identifier("getUser"))
.toSource();
console.log(output);Line 5 parses the code. Line 6 finds every Identifier named fetchUser, line 7 puts a new getUser identifier in each place, and line 8 prints the code.
It prints const user = getUser(7); // the admin and show(getUser, "fetchUser");. The comment is still there, and the string "fetchUser" is untouched: a string is not an identifier. Find and replace would have changed it.
fetchUser changes two files and leaves the third alone.Picture an editor who gets a rulebook for a long book. They go through every chapter, apply each rule where it fits, and leave quotes from other people alone. Before printing, they show the author every marked-up page. A codemod works the same way on code.
- In real life: A rulebook: "write e-mail as email"
- In JavaScript: A codemod's transform
- In real life: Every chapter of the book
- In JavaScript: Every file in the project
- In real life: Changing words, never quotes
- In JavaScript: Changing identifiers, never strings
- In real life: Showing the author the marked-up pages first
- In JavaScript: A dry run and a diff
A codemod changes your source files once, using the tree, so it changes only the code it means to. A Babel plugin uses the same tree, but changes the output on every build and leaves your source alone.
Anatomy of a Babel plugin
A Babel plugin is a function. Babel calls it with its API, and the plugin returns an object with a visitor: one function for each node type it cares about. Babel parses the code, walks the tree, calls the visitor, and prints the result.
import { transform } from "@babel/standalone";
function removeLogs({ types: t }) {
return {
name: "remove-logs",
visitor: {
CallExpression(path, state) {
const callee = path.get("callee");
if (!callee.isMemberExpression()) return;
if (!t.isIdentifier(callee.node.object, { name: "console" })) return;
if (state.opts.keep?.includes(callee.node.property.name)) return;
if (path.parentPath.isExpressionStatement()) path.parentPath.remove();
else path.replaceWith(t.unaryExpression("void", t.numericLiteral(0)));
},
},
};
}
const code = [
'console.log("start");',
"const total = add(2, 3);",
'console.error("oops");',
'const result = console.log("done");',
].join("\n");
console.log(transform(code, { plugins: [[removeLogs, { keep: ["error"] }]] }).code);Line 3 receives Babel's API and takes types from it, named t: helpers that check and build nodes. Line 7 runs for every CallExpression. It gets a path, the node plus its place in the tree, and state, which holds the plugin's options.
Lines 9 to 11 skip calls that aren't console.something, and methods listed in state.opts.keep. Line 12 removes the whole statement when the call stands alone. Line 13 replaces the call with void 0 when its value is used, because removing it would break the const.
Line 25 passes the plugin with its options, { keep: ["error"] }. It prints const total = add(2, 3);, then console.error("oops");, then const result = void 0;.
A replay of the example code, not a view inside a JavaScript engine. The plugin's checks run for real on Acorn's tree of the same code.
script
import { transform } from "@babel/standalone"; function removeLogs({ types: t }) { return { name: "remove-logs", visitor: { CallExpression(path, state) { const callee = path.get("callee"); if (!callee.isMemberExpression()) return; if (!t.isIdentifier(callee.node.object, { name: "console" })) return; if (state.opts.keep?.includes(callee.node.property.name)) return; if (path.parentPath.isExpressionStatement()) path.parentPath.remove(); else path.replaceWith(t.unaryExpression("void", t.numericLiteral(0))); }, }, };} const code = [ 'console.log("start");', "const total = add(2, 3);", 'console.error("oops");', 'const result = console.log("done");',].join("\n");Press Step to see the visitor run for each call, and which line decides what happens to it. This replays the example code; it isn't a view inside the JavaScript engine.
Read each row of the table as: "on a path, this gives you this."
| On a path | What it gives you |
|---|---|
path.node | The node itself, such as a CallExpression. |
path.parent, path.parentPath | The parent node, and the parent's path. |
path.get("callee") | The path of a child, by its field name. |
path.isMemberExpression() | Whether the node has that type. |
path.remove() | Takes the node out of the tree. |
path.replaceWith(node) | Puts another node in its place. |
state.opts | The options the plugin was given in the config. |
Plugins run on every build, so they suit changes that the source should not keep, such as compiling JSX. The transpilers lesson shows Babel in a build.
Codemods with jscodeshift
jscodeshift is a tool for running codemods. A transform is a function that gets one file and an API, and returns the new code. jscodeshift runs it on every file you give it.
import jscodeshift from "jscodeshift";
function transformer(file, api) {
const j = api.jscodeshift;
return j(file.source)
.find(j.CallExpression, { callee: { name: "fetchUser" } })
.forEach((path) => {
path.node.callee.name = "getUser";
})
.toSource();
}
const source = "const user = fetchUser(7); // the admin\nconst list = api.fetchUser(8);";
console.log(transformer({ path: "app.js", source }, { jscodeshift }));Line 3 is the transform. file.source is the file's code, and api.jscodeshift is the library, named j on line 4. Line 6 finds calls whose callee has the name fetchUser: the filter object is compared field by field.
Line 8 renames each one, and line 10 prints the file. Line 14 calls the transform the way jscodeshift would. The first line now says getUser(7), and keeps its three spaces before the comment. api.fetchUser(8) is left alone: that callee is a MemberExpression, which has no name.
Read each row of the table as: "this method does this."
| Method | What it does |
|---|---|
j(source) | Parses the code into a collection that holds the whole file. |
.find(type, filter) | Every node of that type whose fields match the filter. |
.filter(fn) | Keeps only the paths for which fn returns true. |
.forEach(fn) | Runs fn on each path, so you can change its node. |
.replaceWith(fn) | Puts the node that fn returns in each place. |
.size() | How many paths the collection holds. |
.toSource() | Prints the code, reusing the original text wherever nothing changed. |
To run a transform on a project, use the command line. If the transform returns nothing, jscodeshift leaves that file alone. Read each row of the table as: "this command or option does this."
| Command or option | What it does |
|---|---|
npx jscodeshift -t rename.js src/ | Runs the transform in rename.js on every file in src. |
--dry or -d | A dry run: no files are changed. |
--print or -p | Prints each changed file. |
--parser=tsx | Reads TypeScript with JSX. The default parser is babel. |
--from=fetchUser | Options jscodeshift doesn't know are passed to your transform, in options. |
Preserving formatting
A codemod's output goes into a commit that people review. If it changes the spacing of every line, the real change is hard to find. Tools differ a lot here.
import { transform } from "@babel/standalone";
const code = "let cups = 2; // two cups\nconsole.log( cups );";
console.log(transform(code, { plugins: [] }).code);Line 3 has odd spacing on purpose. Line 4 runs Babel with no plugins at all, so nothing is meant to change. But Babel prints the whole tree from scratch, and it prints let cups = 2; // two cups and console.log(cups);: every extra space is gone.
import * as recast from "recast";
const code = "let cups = 2; // two cups\nconsole.log( cups );";
const ast = recast.parse(code);
ast.program.body[0].kind = "const";
console.log(recast.print(ast).code);
console.log(recast.prettyPrint(ast).code);recast keeps the original text of each node. Line 5 changes let to const. Line 6, recast.print, reprints only that declaration, so console.log( cups ); keeps its spaces.
Line 7, recast.prettyPrint, prints the whole tree again instead, and console.log(cups) loses them. jscodeshift's toSource() uses recast.print. Many teams also run Prettier after a codemod.
Testing transforms
A codemod may touch thousands of files, so test it first. The usual test is a fixture: an input, and the output you expect. Good fixtures cover three things: what should change, what must not change, and a second run.
import jscodeshift from "jscodeshift";
function transform(source) {
const j = jscodeshift;
return j(source)
.find(j.BinaryExpression, { operator: "==" })
.forEach((path) => {
path.node.operator = "===";
})
.toSource();
}
const cases = [
["if (a == b) go();", "if (a === b) go();"],
["if (a != b) go();", "if (a !== b) go();"],
['const s = "a == b";', 'const s = "a == b";'],
];
for (const [input, expected] of cases) {
const once = transform(input);
console.log(once === expected ? "pass" : "FAIL", transform(once) === once ? "stable" : "unstable");
}Lines 3 to 11 are a transform that turns == into ===. Lines 13 to 17 list fixtures: the input, then the expected output. Line 19 runs the transform, and line 20 compares the result, then runs it again on its own output.
It prints pass stable, FAIL stable, pass stable. The second fixture caught a bug: the transform forgot !=. The third proves a string is left alone. stable means a second run changes nothing, so running the codemod twice is safe. Exercise 4 fixes the bug.
jscodeshift has helpers for Jest: defineInlineTest takes the input and expected output directly, and defineTest reads them from __testfixtures__/name.input.js and name.output.js.
Running a codemod safely
Before a codemod writes anything, do a dry run: see which files would change and how. Pick a codemod to dry-run it on a small project of three files.
2 of 3 files- 1
export function fetchUser(id) {(removed) - 1
export function getUser(id) {(added)
- 1
import { fetchUser } from "./api.js";(removed) - 1
import { getUser } from "./api.js";(added) - 5
return fetchUser(cart.userId);(removed) - 5
return getUser(cart.userId);(added)
It renames the function, its import and its call. The string "fetchUser is old" in page.js isn't an identifier, so it stays.
Each file shows its changed lines, removed in red and added in green. Look at Use === and !==: it skips cart.userId == null on purpose, because == null matches both null and undefined. A codemod must never change what code does.
A safe routine for real codebases:
- Commit or stash your work, so the codemod's changes are the only ones in the diff.
- Test the transform with fixtures, including code it must not touch.
- Dry-run it with
--dry --print, then run it for real. - Read the diff, run the project's tests and linter, and run Prettier if the team uses it.
- Remove
console.logfrom the code you ship, but keep it in the source - Turn JSX into function calls
- Replace
__DEV__withfalsein production builds - Rename an API in 400 files after a library upgrade
- Move every import of a module to its new path
- Turn old class components into function components
- Warn when someone writes
==in new code - Stop new uses of a deprecated function
Put each job with the tool that fits it: a plugin on every build, a codemod once on the source, or a lint rule while you write.
Common mix-ups
Most mix-ups come from treating code as text, or a one-time change as a build step.
- "A codemod is the same as a Babel plugin." No. A codemod changes the source once; a plugin changes the output on every build.
- "Find and replace is good enough." It also changes strings, comments and other names. And a text replace can break code when it runs twice.
- "Every AST tool keeps formatting." No. Babel prints everything again; recast keeps what didn't change.
- "If the transform runs without errors, it's right." No. Test it with fixtures, including code it must not change.
- "== and === are always safe to swap." No.
x == nullalso matchesundefined.
The table below puts three of these side by side. Read each row as: "people often think this, but this is true."
| Topic | What people think | What is true |
|---|---|---|
| A codemod | It's the same as a Babel plugin. | A codemod changes your source files once; a plugin changes the output on every build. |
| Find and replace | It's good enough for a rename. | It also changes strings, comments and other names, and running it twice can break code. |
| Formatting | Every AST tool keeps your formatting. | Babel prints everything again; recast, used by jscodeshift, keeps what didn't change. |
Practice exercises
Each exercise uses Babel or jscodeshift for real. Guess first, then run it.
What do lines 5 and 6 print?
import jscodeshift from "jscodeshift";
const j = jscodeshift;
const root = j("fetchUser(1);\napi.fetchUser(2);\nfetchUser(3);");
console.log(root.find(j.CallExpression, { callee: { name: "fetchUser" } }).size());
console.log(root.find(j.Identifier, { name: "fetchUser" }).size());import jscodeshift from "jscodeshift";
const j = jscodeshift;
const root = j("fetchUser(1);\napi.fetchUser(2);\nfetchUser(3);");
console.log(root.find(j.CallExpression, { callee: { name: "fetchUser" } }).size());
console.log(root.find(j.Identifier, { name: "fetchUser" }).size());Two calls have a callee named fetchUser. Three identifiers are named fetchUser, counting the one in api.fetchUser. So it prints 2, then 3.
What does line 14 print?
import { transform } from "@babel/standalone";
const seen = [];
function spy() {
return {
visitor: {
Identifier(path) {
seen.push(path.node.name + ":" + path.parent.type);
},
},
};
}
transform("total(price);", { plugins: [spy] });
console.log(seen.join(" "));import { transform } from "@babel/standalone";
const seen = [];
function spy() {
return {
visitor: {
Identifier(path) {
seen.push(path.node.name + ":" + path.parent.type);
},
},
};
}
transform("total(price);", { plugins: [spy] });
console.log(seen.join(" "));total is the call's callee and price its argument, so both have the CallExpression as their parent.
Fill in the visitor on line 6. What is the first line your program prints?
import { transform } from "@babel/standalone";
function varToLet() {
return {
visitor: {
// Add a VariableDeclaration visitor that changes var to let.
},
};
}
console.log(transform("var a = 1;\nconst b = 2;", { plugins: [varToLet] }).code);import { transform } from "@babel/standalone";
function varToLet() {
return {
visitor: {
VariableDeclaration(path) {
if (path.node.kind === "var") path.node.kind = "let";
},
},
};
}
console.log(transform("var a = 1;\nconst b = 2;", { plugins: [varToLet] }).code);The visitor changes the kind of each var declaration, and Babel prints let a = 1; with const b = 2; unchanged.
The second fixture fails. Change lines 6 and 8 so != becomes !== too. What does the second line print now?
import jscodeshift from "jscodeshift";
function transform(source) {
const j = jscodeshift;
return j(source)
.find(j.BinaryExpression, { operator: "==" })
.forEach((path) => {
path.node.operator = "===";
})
.toSource();
}
const cases = [
["if (a == b) go();", "if (a === b) go();"],
["if (a != b) go();", "if (a !== b) go();"],
['const s = "a == b";', 'const s = "a == b";'],
];
for (const [input, expected] of cases) {
const once = transform(input);
console.log(once === expected ? "pass" : "FAIL", transform(once) === once ? "stable" : "unstable");
}import jscodeshift from "jscodeshift";
function transform(source) {
const j = jscodeshift;
return j(source)
.find(j.BinaryExpression)
.filter((path) => path.node.operator === "==" || path.node.operator === "!=")
.forEach((path) => {
path.node.operator += "=";
})
.toSource();
}
const cases = [
["if (a == b) go();", "if (a === b) go();"],
["if (a != b) go();", "if (a !== b) go();"],
['const s = "a == b";', 'const s = "a == b";'],
];
for (const [input, expected] of cases) {
const once = transform(input);
console.log(once === expected ? "pass" : "FAIL", transform(once) === once ? "stable" : "unstable");
}Now both operators get one more =, so the second fixture passes. A second run finds no == or != left, so every line says stable.
This renames the call but not the import, which would break the file. Add a second find for the import. What is the first line your program prints?
import jscodeshift from "jscodeshift";
function transformer(file, api) {
const j = api.jscodeshift;
const root = j(file.source);
root.find(j.CallExpression, { callee: { name: "fetchUser" } }).forEach((path) => {
path.node.callee.name = "getUser";
});
return root.toSource();
}
const source = 'import { fetchUser } from "./api.js";\nfetchUser(7);';
console.log(transformer({ path: "app.js", source }, { jscodeshift }));import jscodeshift from "jscodeshift";
function transformer(file, api) {
const j = api.jscodeshift;
const root = j(file.source);
root.find(j.CallExpression, { callee: { name: "fetchUser" } }).forEach((path) => {
path.node.callee.name = "getUser";
});
root.find(j.ImportSpecifier, { imported: { name: "fetchUser" } }).replaceWith(() => j.importSpecifier(j.identifier("getUser")));
return root.toSource();
}
const source = 'import { fetchUser } from "./api.js";\nfetchUser(7);';
console.log(transformer({ path: "app.js", source }, { jscodeshift }));The call and the import now agree, so the file works: import { getUser } from "./api.js"; then getUser(7);.
Change lines 6 and 8 to use options.from and options.to. What is the second line your program prints?
import jscodeshift from "jscodeshift";
function transformer(file, api, options) {
const j = api.jscodeshift;
return j(file.source)
.find(j.CallExpression, { callee: { name: "fetchUser" } })
.forEach((path) => {
path.node.callee.name = "getUser";
})
.toSource();
}
const source = "fetchUser(1);\nloadCart(2);";
console.log(transformer({ path: "app.js", source }, { jscodeshift }, { from: "loadCart", to: "getCart" }));import jscodeshift from "jscodeshift";
function transformer(file, api, options) {
const j = api.jscodeshift;
return j(file.source)
.find(j.CallExpression, { callee: { name: options.from } })
.forEach((path) => {
path.node.callee.name = options.to;
})
.toSource();
}
const source = "fetchUser(1);\nloadCart(2);";
console.log(transformer({ path: "app.js", source }, { jscodeshift }, { from: "loadCart", to: "getCart" }));With the names from options, the same transform renames loadCart to getCart, and leaves fetchUser alone. On the command line, --from=loadCart --to=getCart would pass them.
Check your understanding
For each question, ask what runs once and what runs on every build, and what would happen if the transform ran twice. Each answer explains why it's right or wrong.
Question 1 of 6What does a Babel plugin function return?
Choose an answer to see the explanation.
Question 2 of 6In a visitor, what is the difference between
pathandpath.node?Choose an answer to see the explanation.
Question 3 of 6What does running this text replace twice print?
Read the code, then predictconst transform = (code) => code.replaceAll("==", "==="); const once = transform("a == b"); console.log(once, "|", transform(once));Choose an answer to see the explanation.
Question 4 of 6What do these two fixture tests print?
Read the code, then predictconst transform = (code) => code.replace("var ", "let "); const cases = [ ["var a = 1;", "let a = 1;"], ["var a = 1; var b = 2;", "let a = 1; let b = 2;"], ]; console.log(cases.map(([input, expected]) => transform(input) === expected).join(" "));Choose an answer to see the explanation.
Question 5 of 6Why does jscodeshift keep your comments and spacing?
Choose an answer to see the explanation.
Question 6 of 6What does
npx jscodeshift --dry -t rename.js src/do?Choose an answer to see the explanation.
Key takeaways
Codemods and plugins turn a tree change into a change across every file.
- A codemod changes source files once; a Babel plugin changes the output on every build.
- A Babel plugin returns a visitor. Each function gets a path, which can remove or replace its node.
- A jscodeshift transform gets a file and returns new code, using
find,forEachandtoSource. - recast reprints only what changed, so your formatting survives; Babel prints everything again.
- Test with fixtures, including code that must not change, and check that a second run changes nothing.
- Dry-run first, read the diff, and never change what the code does.
Remember the one-liner.
Find by shape, change the tree, print only what changed, and test it twice.
Coming next: How linters and formatters work. They walk the same trees to find problems and to print your code neatly.