Skip to main content

recast vs jscodeshift vs ts-morph: Codemods & Code Transformation (2026)

·PkgPulse Team

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

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.

Compare developer tooling and AST packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.