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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| ast-grep | 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 | 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 | 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
// 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.
Related how-tos
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
-
satisfiesarrived in TypeScript 4.9. The release notes introduce it with a palette whose third color is spelledbleu. The comment under that line beginssacrebleu, and the job of the operator in the example is to catch the typo without widening the type of the palette. ↩︎ Back to text -
The ast-grep homepage still introduces the tool as
ast-grep(sg), and the npm package installs both names. Called assgthroughnpx, version 0.45.3 prints a banner sayingsgis deprecated and to useast-grepinstead, then answers anyway. Called by its path, with noast-grepon the PATH, it prints the same banner and fails to find the program it recommends. ↩︎ Back to text -
The TypeScript handbook’s page on JSX says TypeScript disallows angle-bracket type assertions in
.tsxfiles, because the syntax collides with JSX. It recommendsas, which it calls identical in behavior and which works in both kinds of file. A generator that emitsasmakes thetsxparser safe here as well. ↩︎ Back to text