# How to write a jscodeshift codemod for an SDK upgrade

> Rename methods and reshape argument objects at every SDK call site with a jscodeshift transform, dry-run it, review the diff, and audit the misses with ts-morph.

Source: https://voxgig.com/howto/write-a-jscodeshift-codemod-for-an-sdk-upgrade

- Audience: api-consumer
- Level: advanced
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](/howto/jscodeshift-codemod-across-generated-files),
and the [CLI reference](https://jscodeshift.com/run/cli/) 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](https://eslint.org/docs/latest/extend/custom-rules#applying-fixes) | 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](https://jscodeshift.com/build/api-reference/) | 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](https://ts-morph.com/details/types) | 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 fix[^1] 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.

```js title="transform.js"
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.

```js title="transform.js"
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:

```bash
npx jscodeshift -t transform.js --parser=ts --extensions=ts --dry --print --cpus=1 fixture/src/checkout.ts
```

```text output
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](https://prettier.io/docs/) 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.

```js title="transform.js"
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:

```bash
node apply.mjs
```

```text output
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](https://ts-morph.com/setup/)
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.

```js title="rename-with-ts-morph.mjs"
    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
      }
```

```bash
node compare.mjs
```

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

```js title="codemod.test.mjs"
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')
})
```

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

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

```bash
node drift.mjs
```

```text output
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
type[^4] 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

- [Run a codemod across every generated file with jscodeshift](/howto/jscodeshift-codemod-across-generated-files)

- [Detect breaking changes between two OpenAPI versions in CI](/howto/detect-breaking-changes-between-openapi-versions-in-ci)

- [Evaluate a third-party SDK before you depend on it](/howto/evaluate-a-third-party-sdk)

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

[^1]: ESLint's [rule reference](https://eslint.org/docs/latest/extend/custom-rules) makes
`meta.fixable` mandatory 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.

[^2]: The word predates the tools. Facebook's original
[codemod](https://github.com/facebookarchive/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.

[^3]: jscodeshift is a wrapper around [recast](https://github.com/benjamn/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.

[^4]: The fake is cast `as unknown as PaymentsClient`, a shape the
[TypeScript handbook](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions)
sanctions: when one assertion is refused as too conservative, use two, first to `any` or
`unknown`, 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.