recast vs jscodeshift vs ts-morph: Codemods & Code Transformation (2026)
TL;DR
recast prints modified ASTs while preserving the original formatting — only the lines you changed are different in the output. jscodeshift is the Meta (formerly Facebook) framework for writing and running codemods at scale — wraps recast, provides a collection API, and runs transforms on entire codebases in parallel. ts-morph is the TypeScript-first code transformation library — wraps the TypeScript compiler API, understands types and symbols (not just syntax), and has an ergonomic API for navigating and modifying TypeScript source. In 2026: jscodeshift for JavaScript codemods, ts-morph for TypeScript-aware transformations.
Key Takeaways
- recast: ~5M weekly downloads — format-preserving AST printer, foundational for jscodeshift
- jscodeshift: ~3M weekly downloads — Meta's codemod framework, collection API, parallel execution
- ts-morph: ~2M weekly downloads — TypeScript compiler API wrapper, type-aware refactoring
- recast preserves original formatting — only modified AST nodes produce different output
- jscodeshift runs transforms across thousands of files in parallel
- ts-morph gives access to TypeScript's type checker — rename across all usages, find all references
Why Codemods?
Manual refactoring problems:
1000 files to update? → hours of error-prone manual work
Find-and-replace? → misses contextual cases, can break code
IDE refactoring? → limited scope, can't run in CI
Codemods solve this:
- Transform code programmatically using AST manipulation
- Runs at scale (thousands of files in seconds)
- Can be run as part of migration scripts
- Used by: React, Next.js, Vue, Astro to provide automatic migration tools
Real examples:
- React 16 → 17: transform class components to functional (partial)
- lodash → lodash-es: change import syntax
- callbacks → promises → async/await
- CommonJS require() → ES modules import
- Rename variables/functions across entire codebase
- Add TypeScript types to untyped code
recast
recast — format-preserving AST modification:
Core concept
import recast from "recast"
const code = `
function add(a, b) {
return a + b;
}
const result = add(1, 2);
`
// Parse:
const ast = recast.parse(code)
// The AST is annotated with original source positions
// When reprinted, unchanged nodes are reproduced verbatim
// Modify only what you need:
const addDecl = ast.program.body[0]
// Change the function name:
addDecl.id.name = "sum"
// Reprint — only changed lines differ:
const output = recast.print(ast).code
// function sum(a, b) { ← changed
// return a + b; ← unchanged (preserved exactly)
// }
//
// const result = add(1, 2); ← unchanged (still "add" — we only changed the declaration)
Build your own parser
import recast from "recast"
import * as parser from "recast/parsers/babel" // Or: typescript, acorn, esprima
// Use Babel parser (handles JSX, modern syntax):
const ast = recast.parse(code, { parser })
// Use TypeScript parser:
import * as tsParser from "recast/parsers/typescript"
const tsAst = recast.parse(tsCode, { parser: tsParser })
Format-preserving transform example
import recast from "recast"
import * as tsParser from "recast/parsers/typescript"
import { builders as b } from "ast-types"
// Transform: convert var declarations to const/let
const code = `
var x = 1;
var y = [];
y.push(2); // y is mutated — should be let, not const
var z = "hello";
`
const ast = recast.parse(code, { parser: tsParser })
recast.visit(ast, {
visitVariableDeclaration(path) {
if (path.node.kind === "var") {
// Determine if any declaration is reassigned later (simplified):
path.node.kind = "const"
}
this.traverse(path)
},
})
console.log(recast.print(ast).code)
// const x = 1; ← changed
// const y = []; ← changed
// y.push(2); ← PRESERVED EXACTLY (including whitespace, comments)
// const z = "hello"; ← changed
jscodeshift
jscodeshift — Meta's codemod toolkit:
Codemod structure
// A codemod is a file that exports a transform function:
// transforms/convert-require-to-import.ts
import type { FileInfo, API, Options } from "jscodeshift"
export default function transform(
file: FileInfo,
api: API,
options: Options
): string | null {
const j = api.jscodeshift
// Parse the file:
const root = j(file.source)
// Find and transform:
root
.find(j.CallExpression, {
callee: { name: "require" },
})
.filter((path) => {
// Only top-level require() calls:
return path.parent.node.type === "VariableDeclarator"
})
.forEach((path) => {
const requirePath = (path.node.arguments[0] as j.StringLiteral).value
const variableName = (path.parent.node.id as j.Identifier).name
// Replace: const x = require("y") → import x from "y"
j(path.parent.parent).replaceWith(
j.importDeclaration(
[j.importDefaultSpecifier(j.identifier(variableName))],
j.stringLiteral(requirePath)
)
)
})
// Return modified source (or null if no changes):
return root.toSource()
}
Run codemods
# Run on a directory:
npx jscodeshift -t transforms/convert-require-to-import.ts src/
# TypeScript transform on .ts files:
npx jscodeshift -t transform.ts --extensions=ts,tsx --parser=tsx src/
# Dry run (preview without writing):
npx jscodeshift -t transform.ts --dry --print src/
# Parallel (uses worker processes):
npx jscodeshift -t transform.ts --cpus=8 src/
Collection API (jQuery-like)
import type { FileInfo, API } from "jscodeshift"
export default function(file: FileInfo, api: API) {
const j = api.jscodeshift
const root = j(file.source)
// Find all console.log calls:
root
.find(j.CallExpression, {
callee: { type: "MemberExpression", property: { name: "log" } },
})
.filter((path) => path.node.callee.object.name === "console")
// Remove them:
.closest(j.ExpressionStatement)
.remove()
// Find all arrow functions and convert to regular functions:
root
.find(j.ArrowFunctionExpression)
.replaceWith((path) => {
return j.functionExpression(
null,
path.node.params,
path.node.body.type === "BlockStatement"
? path.node.body
: j.blockStatement([j.returnStatement(path.node.body)])
)
})
return root.toSource()
}
Real codemod: lodash to native
// Transform: _.map(arr, fn) → arr.map(fn)
// Transform: _.filter(arr, fn) → arr.filter(fn)
import type { FileInfo, API } from "jscodeshift"
const REPLACEABLE_METHODS = new Set(["map", "filter", "reduce", "forEach", "find"])
export default function(file: FileInfo, api: API) {
const j = api.jscodeshift
const root = j(file.source)
root
.find(j.CallExpression, {
callee: {
type: "MemberExpression",
object: { name: "_" },
},
})
.filter((path) => {
const method = (path.node.callee as j.MemberExpression).property
return method.type === "Identifier" && REPLACEABLE_METHODS.has(method.name)
})
.replaceWith((path) => {
const callee = path.node.callee as j.MemberExpression
const methodName = (callee.property as j.Identifier).name
const [array, ...restArgs] = path.node.arguments
// _.map(arr, fn) → arr.map(fn)
return j.callExpression(
j.memberExpression(array, j.identifier(methodName)),
restArgs
)
})
return root.toSource()
}
ts-morph
ts-morph — TypeScript compiler API wrapper:
Project setup
import { Project, SyntaxKind, SourceFile } from "ts-morph"
// Create a project (reads tsconfig.json):
const project = new Project({
tsConfigFilePath: "tsconfig.json",
// Or: manually specify source files
})
// Add files:
project.addSourceFilesAtPaths("src/**/*.ts")
// Get a source file:
const sourceFile = project.getSourceFileOrThrow("src/index.ts")
Find and rename (type-aware)
import { Project } from "ts-morph"
const project = new Project({ tsConfigFilePath: "tsconfig.json" })
project.addSourceFilesAtPaths("src/**/*.ts")
// Find all usages of a function across the entire project and rename it:
const sourceFile = project.getSourceFileOrThrow("src/utils/calculate.ts")
const fn = sourceFile.getFunctionOrThrow("calculateHealthScore")
// Rename across ALL files that import and use this function:
fn.rename("computePackageHealthScore")
// TypeScript compiler finds all references and renames them correctly
// Save all changes:
project.saveSync()
Add TypeScript types to untyped code
import { Project, SyntaxKind } from "ts-morph"
const project = new Project({ tsConfigFilePath: "tsconfig.json" })
project.addSourceFilesAtPaths("src/**/*.ts")
for (const sourceFile of project.getSourceFiles()) {
// Find all functions without return type annotations:
sourceFile
.getFunctions()
.filter((fn) => !fn.getReturnTypeNode())
.forEach((fn) => {
// Infer the return type from TypeScript's type checker:
const returnType = fn.getReturnType()
const typeText = returnType.getText(fn) // e.g., "Promise<User | null>"
// Add the type annotation:
fn.setReturnType(typeText)
})
}
project.saveSync()
Extract interface from class
import { Project } from "ts-morph"
const project = new Project({ tsConfigFilePath: "tsconfig.json" })
const file = project.addSourceFileAtPath("src/services/UserService.ts")
// Get a class and extract its public interface:
const userServiceClass = file.getClassOrThrow("UserService")
const publicMethods = userServiceClass
.getMethods()
.filter((m) => m.getScope() === "public" || !m.getScope())
// Create an interface with the same methods:
file.addInterface({
name: "IUserService",
isExported: true,
methods: publicMethods.map((m) => ({
name: m.getName(),
parameters: m.getParameters().map((p) => ({
name: p.getName(),
type: p.getType().getText(p),
})),
returnType: m.getReturnType().getText(m),
})),
})
file.save()
Feature Comparison
| Feature | recast | jscodeshift | ts-morph |
|---|---|---|---|
| Format preservation | ✅ Core feature | ✅ (via recast) | ⚠️ (rebuilds output) |
| TypeScript support | ✅ (parser plugin) | ✅ (tsx parser) | ✅ First-class |
| Type-aware analysis | ❌ | ❌ | ✅ (type checker) |
| Parallel execution | ❌ | ✅ | ❌ |
| Collection API | ❌ | ✅ | ✅ (different API) |
| Rename symbols globally | ❌ | ❌ | ✅ |
| Find all references | ❌ | ❌ | ✅ |
| CLI runner | ❌ | ✅ | ❌ |
| Weekly downloads | ~5M | ~3M | ~2M |
When to Use Each
Choose recast if:
- Building a codemod framework that needs format preservation
- Transforming code where whitespace and comments must be preserved
- Low-level AST manipulation in a custom tool
Choose jscodeshift if:
- Writing migration codemods for a library or framework
- Need to run transforms on entire codebases (thousands of files)
- JavaScript or TypeScript (syntax-only) transformations
- Building a codemod CLI tool (like Next.js or React provides)
Choose ts-morph if:
- Transformations that require TypeScript type information
- Renaming a symbol across all its usages project-wide
- Adding type annotations to existing JavaScript/TypeScript
- Building a TypeScript analysis tool (dependency graphs, type coverage)
- Refactoring that's impossible to do with syntax-only AST parsing
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on recast v0.23.x, jscodeshift v0.15.x, and ts-morph v22.x.