Large-Scale Refactors with jscodeshift and ts-morph
Automate large-scale refactors across a TypeScript codebase using jscodeshift and ts-morph, comparing their APIs, performance, and error handling.

A well-known pain: you need to rename a widely-exported type across a thousand files, but a simple grep-and-replace would also rename unrelated local variables with the same identifier. Or you are deprecating a function signature and must update every call site, including files that import it under an alias. In both cases, find-and-replace or regex reaches its limit as soon as the change depends on the lexical structure or type information. Automated code transformation via Abstract Syntax Tree (AST) manipulation — also called a codemod — guarantees that each occurrence is handled correctly, no matter how many files are involved. Two JavaScript/TypeScript libraries dominate this space: jscodeshift (the React community standard) and ts-morph (a more opinionated wrapper around the TypeScript compiler API). Their approaches differ fundamentally, and picking the right one for a given refactor is the difference between a fifteen-minute script and a weekend of debugging.
When to Reach for AST-Based Refactoring
Not every large-scale change needs an AST codemod. If your refactor can be expressed as a set of regex replacements that never touch string literals or comments, or if the change is purely cosmetic (e.g., reformatting), simpler tooling suffices. You hit the wall when the transformation must be structure-aware. Three scenarios commonly trigger the jump to AST:
- Renaming an exported symbol that has the same name as a local variable. You want to rename
export const utils = { … }toexport const helpers = { … }, but only the export declaration and its import sites — not everyutilsvariable in scope. - Changing a function signature. Adding an optional parameter, reordering arguments, or changing a return type requires updating every call site, including spread calls, partial applications, or type annotations.
- Updating import paths after a package restructuring. You must rewrite
import { X } from 'old-package'toimport { X } from '@scope/new-package', but only for that specific binding, not for other imports from the same path.
A subtler case is when the transformation depends on type compatibility. For example, you want to replace all calls to a function where the argument’s type has been deprecated, but only for calls that pass a literal object (not a typed variable). jscodeshift alone cannot do this because it does not run the type checker. ts-morph, by wrapping the TypeScript compiler, exposes a full type-checking API, making such conditional rewrites possible.
On a codebase of over a thousand files, manual changes become untenable. A codemod ensures zero regressions across every module, and because it is scripted, it can be reviewed as a diff, run in CI, and re-applied if the base branch changes.
jscodeshift: The Unix-Pipe Approach
jscodeshift, built on top of recast, treats a file’s AST as a lightweight, read-write snapshot. It is stateless: each transform is a pure function that receives the source, its AST root, and an API object, and returns the transformed source. Recast’s speciality is that it preserves the original whitespace, comments, and string quoting style — the output looks hand-written.
The API is collection-based. j(file) returns a Collection wrapping the root node, and you chain .find(), .filter(), .replaceWith(), .forEach(), and others. Patterns like CallExpression > MemberExpression are expressed as CSS-like selectors. For dynamic code generation, you use j.template.expression or j.template.statement.
// jscodeshift transform: rename `oldFunc` to `newFunc` in calls and imports
module.exports = function (fileInfo, api) {
const j = api.jscodeshift;
const root = j(fileInfo.source);
// Replace calls: oldFunc(...) -> newFunc(...)
root.find(j.CallExpression, { callee: { name: 'oldFunc' } })
.replaceWith((nodePath) => {
const { callee, arguments: args } = nodePath.node;
return j.callExpression(
j.identifier('newFunc'),
args
);
});
// Replace named imports
root.find(j.ImportSpecifier, { imported: { name: 'oldFunc' } })
.replaceWith((nodePath) => {
return j.importSpecifier(
j.identifier('newFunc'),
nodePath.node.local ? j.identifier(nodePath.node.local.name) : null
);
});
return root.toSource();
};Testing a jscodeshift transform is straightforward. Because the function is isolated, you can feed it a test file’s source, compare the output to an expected string, and run the comparison through a git diff. Many teams snapshot the result.
One gotcha: jscodeshift does not type-check. If your transform mistakenly matches a node that looks like a function call but is actually a comment or a template literal, the output might be wrong — but that is rare when you stick to concrete AST selectors. Error handling is minimal; a syntax error in any input file will throw an unhelpful exception unless you wrap the whole transform in a try-catch.
ts-morph: The Type-Aware SDK
ts-morph (formerly ts-simple-ast) is a higher-level abstraction over the TypeScript compiler API. Instead of working with raw AST nodes, you manipulate a mutable document model. You call methods like sourceFile.addImportDeclaration(), functionDeclaration.addParameter(), or classDeclaration.renameSymbol(). The library handles the AST plumbing and updates the source code accordingly.
Its killer feature is built-in type checking. You can retrieve the Type of any node and use methods like type.isString(), type.getProperty('foo'), or type.getCallSignatures() to conditionally rewrite code. This is indispensable for refactors like “remove the second argument from all calls to setState where the first argument is a function” — a scenario that requires knowing whether the first argument is a function type.
// ts-morph: rename an interface property across all usages
import { Project } from 'ts-morph';
const project = new Project({
tsConfigFilePath: 'tsconfig.json',
});
for (const file of project.getSourceFiles()) {
const oldProp = file.getInterface('MyInterface')?.getProperty('oldName');
if (!oldProp) continue;
// ts-morph renaming renames all references (including type annotations)
oldProp.rename('newName');
}
project.saveSync(); // writes all modified filesThe API handles many of the tedious details: if you rename a property via rename, it updates every access obj.oldName to obj.newName and every type annotation { oldName: string } to { newName: string }. This is significantly more powerful than jscodeshift’s manual traversal, and it guarantees that no reference is missed.
The trade-off is performance and memory. Loading a TypeScript program for a large monorepo (5000+ files) can take several seconds, and the type checker runs during operations like getProperty or rename. On the other hand, jscodeshift parses a single file in isolation, so it scales roughly linearly with file count. Ts-morph also throws descriptive errors: if a file has a syntax error or a type mismatch, the TypeScript compiler error message tells you exactly where and why.
Choosing Between jscodeshift and ts-morph
The decision often comes down to three axes: setup cost, performance, and the need for type information.
| Criterion | jscodeshift | ts-morph |
|---|---|---|
| Setup | Install jscodeshift and run jscodeshift -t transform.js src/. No config required. |
Install ts-morph. Create a Project object; optionally load a tsconfig.json to enable type checking. |
| Performance on small refactors (< 500 files) | Fast – each file is parsed independently; no type-checking overhead. | Slower – TypeScript program creation and type resolution adds latency. |
| Type awareness | None. You must guess or rely on naming conventions. | Full TypeScript type checker. Query types, get call signatures, conditionally rewrite. |
| Error handling | Fails silently on malformed files unless you wrap in try-catch. | Throws descriptive TypeScript compiler errors. |
| API ergonomics | Low-level AST nodes; manual construction via j.expression etc. |
High-level mutable methods: addParameter(), renameSymbol(), getClass() . |
| Ideal use case | Bulk renaming of identifiers, import path updates, code formatting. | Type-safe refactors, e.g., rename a method on a specific subclass, or update usages of a deprecated type. |
For a team that is already comfortable with the TypeScript compiler API, ts-morph is a natural fit. For a quick win on a repo without type-level dependencies, jscodeshift is faster to write and run.
Structuring a Codemod for CI and Review
A codemod that runs once in a developer’s shell is useful, but a codemod that runs automatically in CI with guardrails is far more valuable. Here is a pattern I have used repeatedly.
Idempotency. The same codemod should produce identical output if run twice. To guarantee this, guard every transformation with a condition that checks whether the change was already applied. For example, when renaming an import specifier, check that the new specifier does not already exist:
// jscodeshift guard
const newImport = root.find(j.ImportSpecifier, { imported: { name: 'newFunc' } });
if (newImport.size() === 0) {
// proceed with the rename
}Format output automatically. Both tools preserve original formatting, but they can produce odd spacing after node replacements. A post-run step that runs prettier --write on all modified files standardizes the output and catches any malformed whitespace.
Test incrementally. Before running a codemod on the entire codebase, use a --dry flag (both tools support dry runs) and a --verbose flag to list which files would change. Review the diff manually. In CI, you can run the codemod on a small list of files first (e.g., src/lib/), then expand the scope if no issues appear.
Idempotent CI step. A common CI pattern is: run the codemod, then check if any files were modified—if so, fail the build and ask the developer to review the changes. This prevents accidental merging of a partially-transformed codebase. For incremental rollouts, you can also restrict the codemod to only files touched in the current pull request (using git diff --name-only).
Failure Modes and Gotchas
No tool is bulletproof. After running hundreds of codemods, these are the recurring pitfalls.
Syntax errors in input files will crash both tools. Wrap each file processing in a try-catch block, log the filename and error, and continue. This is especially important when running in CI, because an incomplete file from a stash or a partially-merged branch can bring the whole pipeline down.
Template literals and tagged templates are finicky in jscodeshift. When you replaceWith a template literal that contains backticks, recast may double-escape them (producing \`` instead of `` ``). The common workaround is to use j.template.literal for template strings or to manually string-express the node. ts-morph handles template literals more naturally because it reconstructs the entire node.
Multi-pass refactors — e.g., first rename a function, then remove obsolete imports — must be carefully ordered. A single pass that does both steps is ideal. If you must run multiple passes, ensure that the intermediate state is valid: a rename might introduce a duplicate import that the second pass should consolidate, or the second pass might assume a binding that the first pass removed. Test the composition in a small subset before scaling.
CI timeouts can hit a type-aware ts-morph codemod on a 5000-file project. The TypeScript program creation alone can take 30–60 seconds. One mitigation: batch splitting. Run the codemod only on files that changed in the current branch, or use a file list argument (ts-morph accepts a list of file paths). Alternatively, split the codemod into multiple CI jobs that each process a different directory.
Combining Both Tools
jscodeshift and ts-morph are not mutually exclusive. I have used a two-phase approach several times with good results.
A fast initial pass with jscodeshift handles the purely structural changes — renaming function calls, updating import paths, moving property access. Because jscodeshift does not type-check, this phase completes quickly across the entire codebase. Then a second pass with ts-morph cleans up type-only issues: removing unused imports that were orphaned by the rename, fixing type annotations that no longer match, or adding missing imports. The reverse order also works: let ts-morph do the heavy lifting for a type-safe rename, then run jscodeshift to apply formatting rules or to rename leftover string literals that the first tool skipped.
The pipeline can be wrapped in a single Node.js script that calls require('child_process').exec for the jscodeshift runner, then programmatically operates a ts-morph Project, and finally runs Prettier. Output the final diff with git diff --stat so the developer can quickly verify the scale of changes.
When structuring CI around such a pipeline, consider integrating it with GitHub Actions that fail fast and explain why — a pattern where the step exits early if the dry run detects no changes, saving minutes of runtime.
For large monorepos with many packages, using a tool like pnpm Workspaces alongside the codemod can help isolate scope: run the codemod only on the workspace packages that actually need changes, based on the dependency graph. That is exactly what we documented in pnpm Workspaces: Filters, Catalogs, and CI Caching.
Key takeaways
- Choose jscodeshift for fast, structure-aware text transformations that do not require type information; ts-morph when the refactor must be type-safe or when you need to mutate the AST at a higher level of abstraction.
- Wrap every file in a try-catch to handle syntax errors gracefully; within the catch, at least log the filename and the error so you can fix the source file before rerunning.
- Test codemods on a small subset of files using
--dryand review the diff — never commit an untested codemod output across the entire codebase. - Combine both tools in a pipeline: jscodeshift for the quick pass, ts-morph for type-aware corrections, and Prettier for final formatting.
- Persist sessions that may run for minutes by using Terminal sessions that survive a laptop reboot — a codemod on 5000 files is the perfect use case for
tmuxorscreen.
Frequently asked questions
- Can I use jscodeshift for non-TypeScript files?
- Yes, jscodeshift works on any JS, JSX, TS, or TSX file. For pure JavaScript you lose type-aware transformations, but pattern matching and string manipulation still apply.
- How do I test a codemod without running it on the whole codebase?
- Use inline snapshots or fixture files with a test runner like Jest. Transform a small set of files, compare the output against expected output with `git diff` or a snapshot assertion, and iterate before running on the whole monorepo.
- Does ts-morph handle JSX and React components well?
- Yes, ts-morph supports JSX and React components. Some operations on JSX children (e.g., adding props) can be done through the AST, but modifying rendered JSX inside arrow functions may require extra care to preserve formatting.


