How-to › Operate integrations in production

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.

Audience
API consumer
Level
advanced
Topic
Evaluate, adopt and upgrade SDKs
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
An ESLint rule with an automatic fixA small rewrite that must keep running on every save after the upgrade landsA rule to write and ship in your lint config, fixes limited to what one report can express, and a lint pass per fileA one-off upgrade across one codebase, with argument objects to reshape
jscodeshiftA one-off rewrite across many files that needs real logic, with untouched code left formatted as it wasA type-blind match, so a same-named method on an unrelated object is rewritten unless you scope the receiver by handThe receiver’s type decides what changes, or clients arrive from other files
ts-morphThe receiver’s type decides what changes, across a codebase whose tsconfig.json compilesA whole TypeScript program loaded into memory, and any wherever the types are broken, which the pass then skipsA 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.

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

  1. ESLint’s rule reference 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. ↩︎ Back to text

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

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

  4. 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 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. ↩︎ 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.