How-to › Model, configure and generate

How to run a codemod across every generated file with jscodeshift#

Rename a method across a directory of generated TypeScript with a jscodeshift transform, prove a second run changes nothing, then move the change into the template.

Audience
Platform team
Level
advanced
Topic
Engineer templates and AST transforms
Languages
TypeScript
Verified

The generated SDK calls its list method fetchAll, every other client in the company calls it list, and the generator is not due a release for a month. Renaming by hand means editing every resource file, the shared interface, and each call site. A search and replace also rewrites the fetchAll on a cache class that has nothing to do with resources.

What you get

You will end up with a jscodeshift transform that renames a method across generated TypeScript, a proof that a second run changes nothing, and the same change moved into the template. This is for you if generated code needs a fix before its generator does.

Short answer

Write a transform that renames the method where it is declared and where it is called. Run it with --parser=ts --dry --print, then for real with --fail-on-error, then again to confirm every file is skipped. The default Babel parser cannot read TypeScript, and tsx reads <Order>value as JSX. Then make the same change in the template and delete the codemod, or the next regeneration undoes it.

You will need

Node 22 or later, a directory of generated TypeScript under version control, and the generator and templates that wrote it. Verified 2026-10-15 against Node 22.22.0, jscodeshift 17.4.0, ts-morph 28.0.0, which bundles TypeScript 6.0.2, and @ast-grep/cli 0.45.3. If you have not generated TypeScript with ts-morph before, the page on doing that covers the project setup the type check here borrows.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
ast-grepA rename or rewrite you can state as a pattern, across thousands of filesMatches syntax only, so every member with the name is renamed, related or notThe rename has to follow a type, or the rewrite needs logic
jscodeshiftA rewrite that needs JavaScript logic and should leave untouched formatting aloneMatches syntax only, the parser is yours to choose, and the slowest of the three hereTwo unrelated members share the name, or a YAML rule would do
ts-morphThe rename must follow one symbol through its implementations and callersLoads a whole TypeScript program before it renames anything, 5 to 9 s for 3,001 files hereThe change is not a rename, or a pattern rule would do it in a second

jscodeshift and ast-grep match by name, so both rename the fetchAll on the cache class too. ts-morph follows the symbol and leaves the cache alone, at the price of building a program. ast-grep was the fastest by a wide margin and needs no JavaScript, and jscodeshift is the one whose transform can hold arbitrary logic, such as the collision check below.

Look at what was generated

Each resource file comes from one template. The two lines that matter for parsing are the angle-bracket assertion in fetch and the satisfies on the routes.1

// Generated by generate.mjs from model.json. Do not edit.
import type { Lister, Page, Transport } from './client'

export interface Order {
  id: string
}

export class OrderResource implements Lister<Order> {
  constructor(private readonly transport: Transport) {}

  async fetchAll(params: { cursor?: string } = {}): Promise<Page<Order>> {
    return this.transport.get<Page<Order>>('/orders', params)
  }

  async fetch(id: string): Promise<Order> {
    return <Order>await this.transport.get('/orders/' + encodeURIComponent(id))
  }
}

client.ts declares the Lister interface, an each helper that calls fetchAll, and a ResponseCache class whose own fetchAll returns cached entries.

Write the transform

The transform renames an identifier only where it names a member: a method or property declared in a class, an interface, a type literal, or an object. It also renames a key read in a destructuring pattern and the property in a non-computed access. Every access is renamed, so every way of declaring the member has to be renamed with it, or the two halves disagree. A variable called fetchAll, a string 'fetchAll' and obj['fetchAll'] stay as they are.

module.exports = function renameMethod(file, api) {
  const j = api.jscodeshift
  const root = j(file.source)
  let renamed = 0

  root.find(j.Identifier, { name: FROM }).forEach((path) => {
    const parent = path.parent.node
    const isKey = KEYED.has(parent.type) && parent.key === path.node && !parent.computed
    const isCall = MEMBER.has(parent.type) && parent.property === path.node && !parent.computed
    if (!isKey && !isCall) return

    // Renaming onto a member that already exists would merge two methods into one. A type
    // literal keeps its members under `members`, an interface or a class under `body`.
    if (isKey) {
      const owner = path.parent.parent.node
      const members = owner.body ?? owner.properties ?? owner.members ?? []
      if (members.some((m) => m.key && (m.key.name ?? m.key.value) === TO)) {
        throw new Error(`${file.path}: a member named ${TO} already exists beside ${FROM}`)
      }
    }
    // { fetchAll } is short for { fetchAll: fetchAll }. Rename the member, keep the variable.
    if (parent.shorthand) {
      parent.shorthand = false
      parent.key = j.identifier(TO)
    } else {
      path.node.name = TO
    }
    renamed++
  })

  // Returning undefined tells jscodeshift the file is unchanged, which is what makes a second
  // run report every file as skipped rather than rewritten.
  return renamed ? root.toSource() : undefined
}

Dry-run it, then run it

--dry --print writes nothing and prints what the transform would produce. --run-in-band keeps the output in a fixed order, which matters only for the transcript.

npx jscodeshift --parser=ts --dry --print --run-in-band -t rename-method.cjs generated/order.ts
Processing 1 files...
Running in dry mode, no files will be written!
// Generated by generate.mjs from model.json. Do not edit.
import type { Lister, Page, Transport } from './client'

export interface Order {
  id: string
}

export class OrderResource implements Lister<Order> {
  constructor(private readonly transport: Transport) {}

  async list(params: { cursor?: string } = {}): Promise<Page<Order>> {
    return this.transport.get<Page<Order>>('/orders', params)
  }

  async fetch(id: string): Promise<Order> {
    return <Order>await this.transport.get('/orders/' + encodeURIComponent(id))
  }
}

export const orderRoutes = {
  list: '/orders',
  item: '/orders/:id',
} satisfies Record<string, string>

All done.
Results:
0 errors
0 unmodified
0 skipped
1 ok

One line changed, and the assertion, the satisfies and the spacing came through as written. Run it for real on a copy, with --fail-on-error so a file that fails to parse fails the command:

rm -rf work && cp -r generated work && npx jscodeshift --parser=ts --run-in-band --fail-on-error -t rename-method.cjs work/
Processing 4 files...
All done.
Results:
0 errors
0 unmodified
0 skipped
4 ok

Check it worked

Run the same command again. A transform that is idempotent finds nothing left to rename.

npx jscodeshift --parser=ts --run-in-band --fail-on-error -t rename-method.cjs work/
Processing 4 files...
All done.
Results:
0 errors
0 unmodified
4 skipped
0 ok

The diff against the original is the whole change, and the result still type-checks:

diff -r generated work | grep -c '^>' && node typecheck.mjs work
7
work: 4 files, 0 type errors

Seven lines: the interface signature, two calls in each, the three resource methods, and the cache’s fetchAll, which the transform renamed because it matches by name. The suite pins each rule, including the refusal to rename onto an existing list:

node --test codemod.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2969.260563

Compare the three tools on the same files

compare.mjs applies each tool to a fresh copy, type-checks the result, and compares it with what the generator writes once the templates carry the change.

node compare.mjs
                              jscodeshift  ts-morph     ast-grep
lines changed                 7            6            7
ResponseCache.fetchAll        renamed      kept         renamed
type errors afterwards        0            0            0
same as the edited templates  no           yes          no

ts-morph renamed the Lister signature and the language service carried the rename to the three classes that implement it and to the two calls. It changed only those lines, without reprinting the rest of each file. It leaves comments and strings alone unless you pass renameInComments or renameInStrings. On 3,001 generated files on a four-core container, two timed runs took 8 to 13 seconds for jscodeshift, 5 to 9 for ts-morph and under 2 for ast-grep.2

When it goes wrong

The run reports errors and exits 0. The default jscodeshift parser is Babel without TypeScript, and tsx reads <Order>await ... as the start of a JSX element:3

node parsers.mjs
--parser=babel  4 of 4 files fail, first: client.ts This experimental syntax requires enabling one of the following parser plugin(s): "flow", "typescript". (2:7)
--parser=tsx    3 of 4 files fail, first: customer.ts Unexpected token, expected "}" (21:6)
--parser=ts     0 of 4 files fail

--parser=tsx, no --fail-on-error: exit 0, rewrote client.ts
--parser=tsx, --fail-on-error:    exit 1, rewrote client.ts
type check afterwards: 3 errors
  customer.ts:8 TS2420 Class 'CustomerResource' incorrectly implements interface 'Lister<Customer>'.
  invoice.ts:8 TS2420 Class 'InvoiceResource' incorrectly implements interface 'Lister<Invoice>'.
  order.ts:8 TS2420 Class 'OrderResource' incorrectly implements interface 'Lister<Order>'.

The tsx error names line 21, but the cause is the assertion on line 16, so the position points at the wrong code. Worse, the run exited 0 after rewriting the one file it could parse, and the interface now disagrees with every class. Pass --parser=ts and --fail-on-error every time. The flag changes the exit code and nothing else: the same run with it still rewrote client.ts before exiting 1. That is why the earlier real run went to a copy, and why the generated directory should be committed before any run touches it.

The rename reaches a member it should not. The cache’s fetchAll is a different method, and both syntax tools renamed it. Check what else shares the name with grep before running, or use ts-morph when it does.

Move the change into the template

A codemod on generated output lasts until the next regeneration, which rewrites every file from the template. Make the same edit there:

// The change the codemod stands in for, made where it belongs: in the templates.
const TEMPLATE_EDIT = [
  ['resource.ts.tmpl', 'async fetchAll(', 'async list('],
  ['client.ts.tmpl', '  fetchAll(params?', '  list(params?'],
  ['client.ts.tmpl', 'resource.fetchAll(', 'resource.list('],
]

Regenerate, diff generated/ against the output of the codemod, and commit the template change. Only the cache line differs, and the template decides it on purpose. Then delete rename-method.cjs.

When not to do this

Do not run a codemod on generated code and stop there. The next regeneration undoes it without a warning, because the generator is doing its job.

Do not run jscodeshift without --parser=ts and --fail-on-error on TypeScript. The defaults parse the wrong language and report success.

Do not rename by name across code you did not generate. A hand-written module with its own fetchAll is renamed too, and only a type check catches the half of the damage that breaks types.

Do not reach for a codemod at all for one or two generated files with a generator release close. Regenerate.

Last verified

Verified 2026-10-15 against Node 22.22.0, jscodeshift 17.4.0, ts-morph 28.0.0 with TypeScript 6.0.2, and @ast-grep/cli 0.45.3, installed from npm in the sample directory. Every output block is what the command preceding it printed, with the elapsed-time line from jscodeshift left out. The 3,001-file timings were measured on the container and are not re-run by the page’s checks.

Footnotes

  1. satisfies arrived in TypeScript 4.9. The release notes introduce it with a palette whose third color is spelled bleu. The comment under that line begins sacrebleu, and the job of the operator in the example is to catch the typo without widening the type of the palette. ↩︎ Back to text

  2. The ast-grep homepage still introduces the tool as ast-grep(sg), and the npm package installs both names. Called as sg through npx, version 0.45.3 prints a banner saying sg is deprecated and to use ast-grep instead, then answers anyway. Called by its path, with no ast-grep on the PATH, it prints the same banner and fails to find the program it recommends. ↩︎ Back to text

  3. The TypeScript handbook’s page on JSX says TypeScript disallows angle-bracket type assertions in .tsx files, because the syntax collides with JSX. It recommends as, which it calls identical in behavior and which works in both kinds of file. A generator that emits as makes the tsx parser safe here as well. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.