# 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.

Source: https://voxgig.com/howto/jscodeshift-codemod-across-generated-files

- Audience: platform-team
- Level: advanced
- Languages: typescript
- Verified: 2026-10-15
- Published: 2026-10-15

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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [ast-grep](https://ast-grep.github.io/reference/yaml.html) | A rename or rewrite you can state as a pattern, across thousands of files | Matches syntax only, so every member with the name is renamed, related or not | The rename has to follow a type, or the rewrite needs logic |
| [jscodeshift](https://jscodeshift.com/run/cli/) | A rewrite that needs JavaScript logic and should leave untouched formatting alone | Matches syntax only, the parser is yours to choose, and the slowest of the three here | Two unrelated members share the name, or a YAML rule would do |
| [ts-morph](https://ts-morph.com/manipulation/renaming) | The rename must follow one symbol through its implementations and callers | Loads a whole TypeScript program before it renames anything, 5 to 9 s for 3,001 files here | The 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]

```ts title="generated/order.ts"
// 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.

```js title="rename-method.cjs"
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.

```bash
npx jscodeshift --parser=ts --dry --print --run-in-band -t rename-method.cjs generated/order.ts
```

```text output
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:

```bash
rm -rf work && cp -r generated work && npx jscodeshift --parser=ts --run-in-band --fail-on-error -t rename-method.cjs work/
```

```text output
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.

```bash
npx jscodeshift --parser=ts --run-in-band --fail-on-error -t rename-method.cjs work/
```

```text output
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:

```bash
diff -r generated work | grep -c '^>' && node typecheck.mjs work
```

```text output
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`:

```bash
node --test codemod.test.mjs
```

```text output
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.

```bash
node compare.mjs
```

```text output
                              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]

```bash
node parsers.mjs
```

```text output
--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:

```js title="compare.mjs"
// 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.

## Related how-tos

- [Make a Handlebars template produce byte-identical output](/howto/handlebars-byte-identical-output-every-run)

- [Preview a regeneration as a diff before writing files](/howto/preview-a-regeneration-as-a-diff)

- [Generate a code section with code instead of a template](/howto/generate-a-code-section-with-a-component)

- [Rebuild artifacts on model change with a watch loop](/howto/rebuild-artifacts-on-model-change)

## 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.

[^1]: `satisfies` arrived in TypeScript 4.9. The
[release notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html)
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.

[^2]: The ast-grep [homepage](https://ast-grep.github.io/) 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.

[^3]: The TypeScript handbook's [page on JSX](https://www.typescriptlang.org/docs/handbook/jsx.html)
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.