The payments SDK you depend on ships its next major version. charges.create is now spelled
payments.create, the amount and currency sit inside one object, and your code calls it from
forty files. A search and replace renames the in-house ledger’s charges.create along with the
SDK’s, and the first call you fix by hand shows that the reshaped argument is not a rename at all.
What you get
You will end up with a transform that renames methods and reshapes arguments at every call site bound to the SDK client, a diff, and a type-checked audit of what it missed. This is for you if an SDK major version changed call shapes across your code.
Short answer
Write a jscodeshift transform that finds client.charges.create(...) calls, renames the member
chain, and rebuilds the argument object with the builders. Run it with --dry --print and read
the diff before writing anything. Scope the rewrite to names the file binds to the SDK client,
because jscodeshift is type-blind and renames an unrelated object’s method of the same name. Use
ts-morph to audit what the scoped pass missed.
You will need
Node 22 or later, the two packages the committed lockfile pins, and a codebase with a
tsconfig.json the type checker accepts. Verified 2026-10-21 against Node 22.22.0, jscodeshift
17.4.0, and ts-morph 28.0.0, which bundles TypeScript 6.0.2. The sample is a small TypeScript
application over a stand-in @acme/payments package at major version 2, reached through a
paths entry in its tsconfig.json. An in-house Ledger class in the same application shares
the method names. Every run works on a copy under work/, never on the fixture itself. The
--parser=ts flag is explained in
Run a codemod across every generated file with jscodeshift,
and the CLI reference lists the rest.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| An ESLint rule with an automatic fix | A small rewrite that must keep running on every save after the upgrade lands | A rule to write and ship in your lint config, fixes limited to what one report can express, and a lint pass per file | A one-off upgrade across one codebase, with argument objects to reshape |
| jscodeshift | A one-off rewrite across many files that needs real logic, with untouched code left formatted as it was | A type-blind match, so a same-named method on an unrelated object is rewritten unless you scope the receiver by hand | The receiver’s type decides what changes, or clients arrive from other files |
| ts-morph | The receiver’s type decides what changes, across a codebase whose tsconfig.json compiles | A whole TypeScript program loaded into memory, and any wherever the types are broken, which the pass then skips | A JavaScript codebase with no types, or a rename a text tool can do |
jscodeshift sees x.charges.create with no idea what x is, so the in-house ledger below is
renamed along with the SDK client until the transform checks how x was bound. ts-morph asks
the type checker, which costs loading the whole program and a tsconfig.json that compiles. An
ESLint rule with an automatic fix1 keeps running after the upgrade, one fix per report inside
the lint’s own pass. A regular expression is enough when the version 2 name is unique and the
arguments do not change.2
Find the call sites by shape, then by binding
A call site is something.charges.create(...). The transform finds that shape with
find(j.CallExpression, ...) and asks a second question before it rewrites: whether something
is a name this file binds to the SDK client. Without that question, every .charges.create in
the codebase is a target.
module.exports = function transformer(file, api, options) {
const j = api.jscodeshift
const root = j(file.source)
const allowed = options.naive ? null : clientBindings(j, root)
if (allowed && allowed.size === 0) return null
let changed = 0
root
.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
object: { type: 'MemberExpression', property: { type: 'Identifier', name: 'charges' } },
},
})
.forEach((path) => {
const callee = path.node.callee
const method = callee.property.name
const receiver = callee.object.object
if (!Object.hasOwn(RENAMES, method)) return
if (allowed && !(receiver.type === 'Identifier' && allowed.has(receiver.name))) return
callee.object.property = j.identifier('payments')
callee.property = j.identifier(RENAMES[method])
if (method === 'create') reshapeInput(j, path)
changed++
})
return changed ? root.toSource({ quote: 'single' }) : null
}
The bindings come from syntax alone. They are the local name of PaymentsClient imported from
the SDK module, the variables created with new on it or cast to it, and every identifier
annotated with it.
function clientBindings(j, root) {
const names = new Set()
const classNames = new Set()
root.find(j.ImportDeclaration, { source: { value: SDK_MODULE } }).forEach((path) => {
for (const spec of path.node.specifiers) {
if (spec.type === 'ImportSpecifier' && spec.imported.name === CLIENT_CLASS) classNames.add(spec.local.name)
}
})
if (classNames.size === 0) return names
root.find(j.VariableDeclarator, { init: { type: 'NewExpression' } }).forEach((path) => {
const { id, init } = path.node
if (id.type === 'Identifier' && init.callee.type === 'Identifier' && classNames.has(init.callee.name)) names.add(id.name)
})
// `const fake = { ... } as unknown as PaymentsClient`, the shape of a hand-rolled test double.
root.find(j.VariableDeclarator, { init: { type: 'TSAsExpression' } }).forEach((path) => {
const { id, init } = path.node
const cast = init.typeAnnotation
if (id.type === 'Identifier' && cast.type === 'TSTypeReference' && classNames.has(cast.typeName.name)) names.add(id.name)
})
root.find(j.Identifier).forEach((path) => {
const annotation = path.node.typeAnnotation && path.node.typeAnnotation.typeAnnotation
if (annotation && annotation.type === 'TSTypeReference' && classNames.has(annotation.typeName.name)) names.add(path.node.name)
})
return names
}
Nothing in those three searches knows a type. A client that comes out of a function in another
file, const sdk = getClient(), carries none of the marks, so the scoped pass leaves it alone,
and the comparison below shows the cost. --naive turns the scoping off, which is what a
regular expression does.
Run the dry run first:
npx jscodeshift -t transform.js --parser=ts --extensions=ts --dry --print --cpus=1 fixture/src/checkout.ts
Processing 1 files...
Spawning 1 workers...
Running in dry mode, no files will be written!
Sending 1 files to free worker...
import { PaymentsClient } from '@acme/payments'
const client = new PaymentsClient(process.env.ACME_KEY ?? 'sk_test_local')
export async function checkout(cartTotal: number, token: string) {
const charge = await client.payments.create({
amount: {
value: cartTotal,
currency: 'EUR'
},
paymentMethod: token
})
return charge.id
}
export function status(chargeId: string) {
return client.payments.get(chargeId);
}
All done.
Results:
0 errors
0 unmodified
0 skipped
1 ok
--dry writes nothing and --print shows the result, so the first run is a reading exercise.
The printer reprinted the two statements it touched in its own style, with a blank line inside
the object and a semicolon at the end.3 Run your formatter after
the codemod and before anyone reads the diff.
Reshape the argument, and leave what you cannot
Renaming is the small part. The version 3 create takes { amount: { value, currency }, paymentMethod }, so the transform rebuilds the object literal with the builders: amount gets
a nested object as its value, currency is removed, and source is renamed. An argument the
call does not show whole, a variable built elsewhere or an object with a spread, cannot be
reshaped by looking at the call. The transform leaves a TODO comment before the statement instead
of guessing, never inside it, where a line break after yield would end the expression.
function reshapeInput(j, path) {
const [input] = path.node.arguments
const props = input && input.type === 'ObjectExpression' ? input.properties : []
const keyOf = (p) => {
if ((p.type !== 'ObjectProperty' && p.type !== 'Property') || p.computed) return null
return p.key.type === 'Identifier' ? p.key.name : typeof p.key.value === 'string' ? p.key.value : null
}
const byName = (name) => props.find((p) => keyOf(p) === name)
const amount = byName('amount')
const currency = byName('currency')
const source = byName('source')
if (!amount || !currency || props.some((p) => keyOf(p) === null)) {
// On the enclosing statement, never inside the expression: after `yield`, a line comment
// ends the yield there and leaves the call as a statement of its own.
const closest = j(path).closest(j.Statement).get()
const statement = /^Export/.test(closest.parent.node.type) ? closest.parent.node : closest.node
statement.comments = [...(statement.comments || []), j.commentLine(' TODO(codemod): reshape this ChargeInput by hand, see the version 3 migration guide')]
return
}
amount.value = j.objectExpression([
j.property('init', j.identifier('value'), amount.value),
j.property('init', j.identifier('currency'), currency.value),
])
amount.shorthand = false
input.properties = props.filter((p) => p !== currency)
if (source) {
source.key = j.identifier('paymentMethod')
source.shorthand = false
}
}
Then run it for real, on a copy, and read the diff:
node apply.mjs
jscodeshift over src/: 2 files changed, 4 skipped, 0 unchanged, 0 errors
--- a/src/checkout.ts
+++ b/src/checkout.ts
@@ -3,10 +3,17 @@
const client = new PaymentsClient(process.env.ACME_KEY ?? 'sk_test_local')
export async function checkout(cartTotal: number, token: string) {
- const charge = await client.charges.create({ amount: cartTotal, currency: 'EUR', source: token })
+ const charge = await client.payments.create({
+ amount: {
+ value: cartTotal,
+ currency: 'EUR'
+ },
+
+ paymentMethod: token
+ })
return charge.id
}
export function status(chargeId: string) {
- return client.charges.retrieve(chargeId)
+ return client.payments.get(chargeId);
}
The diff is the review artifact. Both changed files are calls the shape and the binding agreed
on. The four skipped files returned null from the transform, which is how a transform tells
jscodeshift it had nothing to do; returning the source unchanged reports the file as unmodified
instead.
Compare the type-blind pass with the type checker
ts-morph answers the question jscodeshift cannot. It loads the project
from its tsconfig.json, finds the same call shape, and asks for the type of the receiver. A
call is rewritten when that type is declared in the SDK’s own file and skipped otherwise.
for (const call of calls) {
const callee = call.getExpression()
const inner = callee.getExpression()
const receiver = inner.getExpression()
const type = receiver.getType()
const symbol = type.getSymbol()
const declaredIn = symbol?.getDeclarations()[0]?.getSourceFile().getFilePath() ?? ''
const where = `${relative(projectDir, file.getFilePath())}:${call.getStartLineNumber()}`
const typeName = symbol?.getName() ?? type.getText()
if (!declaredIn.endsWith('vendor/acme-payments.ts')) {
report.push({ where, action: 'skipped', type: typeName })
continue
}
node compare.mjs
call site receiver wanted naive scoped ts-morph
src/checkout.ts client = new PaymentsClient() rewrite rewritten rewritten rewritten
src/subscriptions.ts sdk: PaymentsClient rewrite rewritten rewritten rewritten
src/webhooks.ts sdk = getClient() rewrite rewritten skipped rewritten
src/report.ts ledger = new Ledger() keep rewritten skipped skipped
Read the four rows as a scorecard. The naive pass rewrites the ledger, which is a bug shipped to
every file at once. The scoped pass gets the ledger right and misses the client that came out of
getClient(), because nothing in that file names the class. ts-morph gets all four, at the cost
of loading the whole program, a cost that grows with the repository and with every type error in
it.
Check it worked
The suite runs the transform through applyTransform, jscodeshift’s own test helper, on small
sources. It covers the rename and reshape, the typed parameter, the cast test double, the
unrelated object in both modes, and the TODO comment. The rest run it twice, feed both tools
an inherited method name, rewrite the markdown, and run from a path with a space.
test('an unrelated object with the same method names is left alone, unless the run is naive', () => {
const source = "import { Ledger } from './ledger'\nconst ledger = new Ledger()\nledger.charges.create({ amount: 1, currency: 'EUR', source: 'manual' })"
assert.equal(run(source), '', 'no SDK import in the file, so nothing changes')
const both = run(IMPORT + source + "\nclient.charges.retrieve('ch_1')")
assert.match(both, /ledger\.charges\.create\(/, 'beside the SDK client, the ledger keeps its call')
assert.match(both, /client\.payments\.get\('ch_1'\)/)
assert.match(run(source, { naive: true }), /ledger\.payments\.create\(/)
})
test('an argument that is not an object literal gets a TODO comment, not a guess', () => {
const out = run(IMPORT + 'const input = build()\nawait client.charges.create(input)')
assert.match(out, /\/\/ TODO\(codemod\): reshape this ChargeInput by hand/)
assert.match(out, /client\.payments\.create\(input\)/)
})
test('running the transform over its own output changes nothing', () => {
const once = run(IMPORT + "client.charges.create({ amount: total, currency: 'EUR', source: token })")
assert.equal(run(once), '', 'a second run finds no version 2 call to rewrite')
})
node --test codemod.test.mjs
1..13
# tests 13
# suites 0
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1794.351241
# pass 13 is the line to look for. The second-run test matters most: a transform that rewrites
its own output cannot be re-run after a merge, and a pass over a half-migrated branch is exactly
when you need to.
When it goes wrong
The first production call to retryFailed throws, because sdk.charges no longer exists in
version 3, although the pull request was green. The codemod ran over src/ only, and the one
call it could not see there was the client returned by getClient(). The tests kept passing
because their fake still has the version 2 shape, and the README still teaches the version 2
call. Ask the type checker what is left:
node drift.mjs
after the codemod ran over src/
src/webhooks.ts:6 sdk.charges.create receiver type PaymentsClient
test/checkout.test.ts:20 fake.charges.create receiver type PaymentsClient
README.md 2 mentions of the version 2 methods
after running it over test/ and the fenced code in README.md
src/webhooks.ts:6 sdk.charges.create receiver type PaymentsClient
README.md 1 mention of the version 2 methods
The audit lists two calls on version 2 after the first pass. One is the client from
getClient(), which syntax cannot see. The other is the test’s fake, which is cast to the client
type4 and so counts as a client. A pass over test/ rewrites the second. The fake’s own shape, charges: { create },
is not a call site, so the fake changes by hand, and until it does the rewritten test cannot pass
against it. docs.mjs runs the same transform through the README’s fenced code; the inline
mention in prose stays, counted, for a person. The webhook call needs ts-morph or an edit.
The run prints No files selected, nothing to do. jscodeshift picks files by extension before
parsing, and its default list, like --extensions=ts, leaves out .mts and .cts. Pass every
extension the code uses, as in --extensions=ts,mts,cts.
When not to do this
Do not write a codemod for a rename a regular expression can do. charges.retrieve( is unique in a codebase
with no in-house charges, its arguments do not change, and an editor’s replace-all plus a type
check finishes before the transform’s first test is written. The codemod earns its place when
the arguments change shape, when the same method name lives on other objects, or when the
rewrite will run again on other repositories.
Do not run ts-morph over a project whose tsconfig.json does not compile cleanly. Where the
checker gives up, a receiver’s type is any and its symbol is missing, so the audit in the
preceding section files the call under skipped. A broken build turns the type-aware pass into one
that changes nothing.
Do not expect the codemod to fix the test doubles. A fake built to the version 2 shape is data rather than a call, and only calls are rewritten. Budget the hand edits, and treat a green suite after a codemod with suspicion until the fakes have changed.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, jscodeshift 17.4.0, and ts-morph 28.0.0, which bundles
TypeScript 6.0.2. Every output block is what the command preceding it printed. The SDK is a
stand-in file inside the fixture, reached through a paths entry rather than installed from
npm, and the ESLint approach was read from its documentation and not run.
Footnotes
-
ESLint’s rule reference makes
meta.fixablemandatory for a rule that fixes anything, and says ESLint throws an error whenever a rule without it attempts to produce a fix. A rule has to announce that it rewrites code before it is allowed to, a courtesy the other two tools in the table do not offer. ↩︎ Back to text -
The word predates the tools. Facebook’s original codemod was a Python script, announced around December 2008, whose README offers regular expressions for the 80% case and Python for the rest, with a person approving each change. The repository was archived on June 10, 2021. The tools that inherited the name parse the code instead, and the approval survives as the diff review. ↩︎ Back to text
-
jscodeshift is a wrapper around recast, whose README puts its design in one sentence: it reprints only those parts of the syntax tree that you modify. The parts you do modify fall back to what the README calls a generic pretty printer, which is where the blank line and the semicolon came from. The identity it promises, that printing a parsed file returns the file, is kept for the nodes you leave alone. ↩︎ Back to text
-
The fake is cast
as unknown as PaymentsClient, a shape the TypeScript handbook sanctions: when one assertion is refused as too conservative, use two, first toanyorunknown, then to the type you want. The handbook presents it as an escape hatch. In a test suite it is the usual way to tell the checker that an object is something it is not, which is why the audit counts the fake as a client. ↩︎ Back to text