Skip to main content

Guide

recast vs jscodeshift vs ts-morph 2026

Compare recast, jscodeshift, and ts-morph for writing codemods and automated code transformations. AST-based refactoring, TypeScript support, preserving.

·PkgPulse Team·
0

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

Featurerecastjscodeshiftts-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

TypeScript-Aware Transformations with ts-morph

ts-morph's killer feature is access to TypeScript's type checker during code transformation. This enables transformations that are impossible with syntax-only AST tools: finding all call sites of a function that returns Promise<User>, renaming an interface property and automatically updating all object literals that implement it, or generating mock implementations from interface definitions. The type checker traversal is significantly slower than jscodeshift's syntax-only approach — expect ts-morph transformations on a large codebase to take minutes rather than seconds. For interactive developer tooling (IDE-style analysis, code generation on save), the latency is acceptable. For batch codemods run in CI, prefer jscodeshift for syntax transformations and reserve ts-morph for the cases where type information is genuinely necessary.

Writing Production-Ready Codemods

A codemod that runs on 5000 files needs more care than a script that runs on 50. Start with a dry run (--dry --print) and review a sample of the output before committing to file writes. Handle edge cases: what happens if the pattern you're searching for appears in a string literal? In a comment? In a test file that should be excluded? jscodeshift's collection API makes it easy to accidentally match too broadly — use .filter() to constrain matches to the specific AST context you intend. Version control your codemod transforms alongside your codebase and run them in separate commits to produce clean git history. If a transform fails on a file (throws an error), jscodeshift reports it and continues rather than crashing the entire run — review the error output before assuming success.

Format Preservation and Code Review Quality

recast's format-preserving output is not merely aesthetic — it directly affects code review quality. When a codemod modifies 500 files, reviewers need to distinguish intentional changes from formatting noise. A codemod that rebuilds the AST from scratch (like Babel transforms or ts-morph's default output) produces diffs that show every whitespace and parenthesis change, making review impractical. recast's approach of reprinting only modified AST nodes means the git diff shows exactly what the codemod changed — nothing more. This is why Next.js, React, and Vue all ship their migration codemods built on jscodeshift (which uses recast under the hood): they know their users will review the changes before committing, and minimal diffs are essential to that workflow. For internal codemods with no review requirement, ts-morph's format quality is adequate.

Integration with Large Monorepos

Running codemods on monorepos requires understanding how each tool handles module boundaries. jscodeshift transforms each file independently — it does not understand cross-file relationships, so renaming an export requires both the export site and all import sites to be matched by the same codemod. ts-morph operates on an entire TypeScript project, meaning cross-file symbol resolution works correctly: fn.rename() finds and renames all usages including files that import from other files in the project. For Nx or Turborepo monorepos, configure ts-morph with the root tsconfig.base.json and add all packages' source paths to ensure complete cross-package analysis. The performance cost of loading an entire monorepo into ts-morph's language service is significant — scope your transforms to specific packages when possible using project.addSourceFilesAtPaths() with targeted globs.

Ecosystem Context and Migration Tooling

The most impactful codemods in the JavaScript ecosystem are the migration scripts shipped by major frameworks. React's transform for removing deprecated lifecycle methods, Next.js's next-codemod CLI for upgrading between major versions, and Vue's migration build all demonstrate the pattern. Study these open-source codemods to learn production codemod patterns: they show how to handle AST edge cases, how to detect whether a transform is applicable to a specific file, and how to produce meaningful output messages. The @next/codemod package's transforms are available on GitHub and serve as excellent references for jscodeshift patterns. ts-morph's official documentation includes a cookbook of common transformation patterns that covers most real-world use cases. For teams evaluating whether to invest in codemods, the rule of thumb is: if a refactoring would take more than two hours manually across your codebase, a codemod is worth writing.

Security and Access Control in Automated Transformations

Codemods that touch authentication, authorization, or cryptography code deserve extra scrutiny. An automated transform that moves security-critical code paths risks silently introducing vulnerabilities if the transform logic has edge cases. Before running a security-adjacent codemod, add explicit test coverage for the security behavior you expect, run the codemod in dry-run mode and review every changed file, and run your test suite immediately after. Consider requiring manual code review sign-off on the diff even if the CI pipeline passes. This is not paranoia — a codemod that removes an await from an authorization check or incorrectly threads a parameter through a security function can introduce a critical vulnerability that passes tests because tests rarely cover all authorization bypass scenarios. For these codemods, ts-morph's type-aware approach is preferable since it can validate that transformed code retains the same type signatures the security logic depends on.

Compare developer tooling and AST packages on PkgPulse →

See also: AVA vs Jest and unplugin vs Rollup Plugin vs Vite Plugin, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.