Automated AST Refactoring: Writing Custom jscodeshift Codemods for Massive Codebases
Tired of manual refactoring? Learn how to inspect Abstract Syntax Trees, write custom jscodeshift codemods, automate breaking API migrations across 1,000+ files, and test your transforms with Vitest.
The Limits of Find-and-Replace in Enterprise Monorepos
Every software engineering organization eventually faces a massive breaking migration: deprecating a legacy Button component API, renaming a design token prop, or upgrading from TanStack Query v4 to v5 across 800 repository files.
Using regex search-and-replace for these refactors is perilous. Regex lacks contextual awareness: it cannot distinguish between a prop named 'size' on an Icon vs a 'size' prop on an Avatar, nor can it safely insert new import statements without corrupting whitespace.
Abstract Syntax Tree (AST) codemods solve this completely. A codemod parses source code into a structured tree representation, queries specific syntax nodes (such as JSXElement or ImportDeclaration), manipulates properties programmatically, and prints clean, formatted code back to disk.
In this guide, we learn how to inspect ASTs and write custom jscodeshift codemods that refactor hundreds of files in seconds.
How an Abstract Syntax Tree (AST) Represents Code
To a compiler, JavaScript code is not a string of text; it is a hierarchical tree of nodes:
A line like import { Button } from '@/components/ui' is parsed into an ImportDeclaration node, containing an ImportSpecifier (Button) and a StringLiteral source ('@/components/ui').
JSX like <Button variant='primary'>Click</Button> becomes a JSXElement node with JSXOpeningElement, JSXAttribute ('variant'), and JSXText children.
Once you understand this tree model, refactoring is simply tree traversal and node replacement.
Writing a Custom Codemod: Renaming Props Across Components
Let's author a practical codemod: our team is replacing the legacy prop isPrimary={true} on <Button> with the new modern design system prop variant='primary'.
import type { API, FileInfo } from "jscodeshift";
export default function transformer(file: FileInfo, api: API) {
const j = api.jscodeshift;
const root = j(file.source);
// 1. Find all JSX elements named 'Button'
root.find(j.JSXElement, {
openingElement: { name: { name: "Button" } },
}).forEach((path) => {
const { attributes } = path.node.openingElement;
if (!attributes) return;
// 2. Find and replace legacy 'isPrimary' prop
path.node.openingElement.attributes = attributes.map((attr) => {
if (
attr.type === "JSXAttribute" &&
attr.name.name === "isPrimary"
) {
// Replace with variant="primary"
return j.jsxAttribute(
j.jsxIdentifier("variant"),
j.stringLiteral("primary")
);
}
return attr;
});
});
// 3. Return formatted source code
return root.toSource({ quote: "single" });
}Automating Import Specifier Rewrites
Codemods can also update import paths. If Button was moved from '@/components/ui' to '@codexox/design-system', query ImportDeclaration nodes, verify that Button is imported, and rewrite the source literal cleanly.
export function rewriteImport(j: any, root: any, componentName: string, newPath: string) {
root.find(j.ImportDeclaration).forEach((path: any) => {
const specifiers = path.node.specifiers;
const hasComponent = specifiers?.some(
(s: any) => s.local?.name === componentName
);
if (hasComponent) {
path.node.source = j.stringLiteral(newPath);
}
});
}Unit Testing Codemods with Vitest Snapshot Assertions
Never run an untested codemod across a 500,000-line monorepo. Author snapshot unit tests that feed input code snippets into your transform and assert that output matches expected targets.
Vitest provides instantaneous feedback, enabling developers to test complex JSX edge-cases, multiline comment preservation, and conditional expressions with complete confidence.
import { describe, it, expect } from "vitest";
import jscodeshift from "jscodeshift";
import transformer from "../transforms/migrateButtonProp";
function applyTransform(inputSource: string): string {
const api = {
jscodeshift: jscodeshift.withParser("tsx"),
j: jscodeshift.withParser("tsx"),
stats: () => {},
report: () => {},
};
return transformer({ source: inputSource, path: "test.tsx" }, api) || inputSource;
}
describe("migrateButtonProp AST codemod", () => {
it("converts isPrimary={true} into variant='primary'", () => {
const input = `<Button isPrimary={true}>Confirm Action</Button>;`;
const output = applyTransform(input);
expect(output).toBe(`<Button variant='primary'>Confirm Action</Button>;`);
});
it("leaves unrelated props intact without altering sibling formatting", () => {
const input = `<Button isPrimary={true} size="large" onClick={handleClick}>Submit</Button>;`;
const output = applyTransform(input);
expect(output).toContain(`variant='primary'`);
expect(output).toContain(`size="large"`);
});
});Running Codemods in Parallel Across Monorepos
Execute jscodeshift with multi-core parallelism using npx jscodeshift -t transforms/migrateButtonProp.ts src/ --extensions=ts,tsx --parser=tsx.
jscodeshift utilizes all available CPU cores to refactor thousands of files in under 10 seconds.
Complex Pattern Matching: Transforming Method Calls and State Hooks
Beyond simple JSX attribute replacements, AST transforms can refactor complex JavaScript logic. For instance, you can query CallExpression nodes to migrate legacy React class lifecycles (componentDidMount) or replace deprecated query client method calls with modern TanStack Query v5 syntax across an entire codebase.
Identify target MemberExpression identifiers (like queryClient.getQueryData), replace arguments with modern object signature shapes, and insert new import specifiers dynamically.
export function migrateQueryCall(j: any, root: any) {
// Query: queryClient.getQueryData(['todos']) -> queryClient.getQueryData({ queryKey: ['todos'] })
root.find(j.CallExpression, {
callee: {
object: { name: "queryClient" },
property: { name: "getQueryData" },
},
}).forEach((path: any) => {
const args = path.node.arguments;
if (args.length === 1 && args[0].type === "ArrayExpression") {
// Wrap bare array argument into { queryKey: [...] } object expression
path.node.arguments = [
j.objectExpression([
j.objectProperty(j.identifier("queryKey"), args[0]),
]),
];
}
});
}What parser should be configured in jscodeshift for modern TypeScript and React?
Always specify --parser=tsx or configure babel-typescript in jscodeshift so that TypeScript generics and JSX syntax are parsed without syntax errors.
How does jscodeshift preserve original code formatting and comments?
jscodeshift uses recast under the hood, which performs non-destructive token tracking to preserve indentation, semicolons, and comments on unmodified lines.
Can codemods break git blame history?
Yes, large automated refactors create sweeping diffs. Teams should add the codemod commit SHA to a .git-blame-ignore-revs file to keep git blame pristine.
Can codemods format code with Prettier automatically?
Yes. It is common practice to run prettier --write on changed files immediately after the jscodeshift run completes.
What is the difference between jscodeshift and ts-morph?
jscodeshift is fast, lightweight, and focused on AST transforms across any JS/TS code. ts-morph wraps the full TypeScript type-checker compiler API, allowing you to query semantic types (like type-of variable).
How can developers safely preview codemod changes before modifying disk files?
Pass the --dry flag to jscodeshift (npx jscodeshift -d -p -t transform.ts src/) to print diff previews to the terminal without writing changes to disk.
How fast is jscodeshift on multi-thousand file enterprise codebases?
Because jscodeshift forks worker processes across all physical CPU threads, it typically processes 1,000 files in under 6 seconds, making it orders of magnitude faster than manual refactoring.