# Modernizing JavaScript and TypeScript with codemods

> JavaScript and TypeScript modernization pairs framework codemods, jscodeshift, ESLint, and tsc with Droids. Tools make the mechanical changes, Droids fix the residue and write tested codemods, and a dry-run rerun proves nothing is left.

URL: https://factory.com/articles/javascript-typescript-codemods
Published: 2026-11-14
Updated: 2026-11-14

JavaScript and TypeScript modernization usually means a framework major version, a move from JavaScript to TypeScript, or the removal of an internal API that hundreds of files call. Codemods do the repeatable part of that work well. The remainder needs someone to read the code.

That split suggests the setup. Deterministic tools such as framework codemods, jscodeshift, ESLint, and the TypeScript compiler make the mechanical changes and check the result. Factory's Droids handle what the tools leave behind and write new codemods when a pattern repeats. A dry-run rerun at the end confirms that the tools have nothing left to change.

## The deterministic JavaScript and TypeScript toolchain

Framework teams publish codemods for their own breaking changes, and those should run first. Angular's [`ng update`](https://angular.dev/cli/update) updates packages and runs the migration schematics they ship. With `--migrate-only` and `--from`, it reruns migrations for a single package without changing the installed version. The [React codemod repository](https://github.com/reactjs/react-codemod) holds transforms such as `react/19/replace-reactdom-render`, and its README recommends running them with `npx codemod`.

Next.js documents its [codemods and upgrade command](https://nextjs.org/docs/app/guides/upgrading/codemods). `npx @next/codemod upgrade major` updates Next.js, React, and React DOM and applies the recommended codemods, while `npx @next/codemod <transform> <path>` runs one transform.

[jscodeshift](https://github.com/facebook/jscodeshift) is the general-purpose runner. It applies a transform module to every file it is given, uses recast to preserve original formatting where it can, and supports TypeScript through `--parser=tsx`. Its `--dry` flag writes nothing, and `--print` shows the transformed source. ts-morph covers transforms that need types. It wraps the TypeScript compiler API, so a transform can ask whether a call's receiver really is the API client rather than matching on a variable name.

Linters finish the mechanical pass. The [ESLint CLI](https://eslint.org/docs/latest/use/command-line-interface) applies rule fixes with `--fix` and previews them with `--fix-dry-run`. typescript-eslint's [typed linting](https://typescript-eslint.io/getting-started/typed-linting/) adds rules that use type information through `parserOptions.projectService`, which catches problems such as floating promises that syntax alone cannot.

For JavaScript-to-TypeScript moves, [ts-migrate](https://github.com/airbnb/ts-migrate) from Airbnb initializes a `tsconfig.json`, renames files to `.ts` and `.tsx`, and runs codemods that get the project compiling. Its README says it is designed around Airbnb projects and to use it at your own risk, so treat its output as a starting point. The `any` types and suppression comments it leaves are the work list for the next stage. Its `--sources` flag limits a run to a glob, which lets a large application move one directory at a time instead of in a single change.

These commands make up a typical pass on a React and TypeScript codebase.

<div className="not-prose my-6 flex flex-col gap-3 [&_button]:!w-auto">
  <div className="grid grid-cols-1 gap-y-5 md:grid-cols-[max-content_1fr] md:gap-x-4">
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Framework codemod.</strong> Replaces ReactDOM.render calls with createRoot.</span>
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Team codemod.</strong> Applies a custom transform to every TypeScript file under src.</span>
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Lint fixes.</strong> Applies every available rule fix and reports what remains.</span>
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Type gate.</strong> Type-checks the whole project without writing output.</span>
  </div>
</div>

Pin every codemod version. `npx codemod`, `npx @next/codemod@latest`, and an unpinned jscodeshift resolve to whatever was published most recently, so a rerun next week can produce a different diff. Record exact versions in `package.json` or in the commands themselves, and the mechanical commit stays reproducible.

Order matters too. Run framework codemods before team transforms, and team transforms before `eslint --fix`, because lint fixes assume the final shape of the code. In a monorepo, start with packages that depend on no other internal package, so each package's gate runs against dependencies that are already migrated.

## What Droids handle after the codemods

Codemods stop at the edge of what can be matched. Past that edge, the work falls into a few kinds.

Type errors come first. A framework upgrade often tightens types, and a JavaScript-to-TypeScript move produces hundreds of `any` annotations and suppression comments. Each one needs a real type, which means reading how the value flows. A Droid can take one file at a time, replace the suppressions with correct types, and rerun `tsc`.

Some breaking changes have no codemod. Framework migration guides list behavior changes in rendering, caching, routing, or event handling that no transform can apply blindly, because the correct choice depends on the product. A Droid reads the guide and the affected code, proposes the change, and updates tests to describe the new behavior.

Failing tests are the third kind. Snapshot changes, timing differences, and mocks built for an old API all surface after the mechanical pass.

New codemods are the fourth and most durable kind. When the Droid sees the same edit repeating, it writes a jscodeshift or ts-morph transform with tests. The transform becomes part of the deterministic toolchain and can be rerun after every rebase until the branch merges.

## Writing a jscodeshift codemod with tests

The transform below renames an internal API from `client.getUser` to `client.fetchUser`. It matches on the receiver name, which is a deliberate simplification. A version that must tell two clients apart would use ts-morph to check the receiver's type.

```js title="transforms/rename-get-user.js"
module.exports = function transformer(file, api) {
  const j = api.jscodeshift
  return j(file.source)
    .find(j.CallExpression, {
      callee: { object: { name: 'client' }, property: { name: 'getUser' } },
    })
    .forEach((path) => {
      path.node.callee.property = j.identifier('fetchUser')
    })
    .toSource()
}
```

jscodeshift's `testUtils` module provides `defineInlineTest`, which takes the transform, its options, the input, the expected output, a test name, and test options that select the parser. These two Jest tests cover the rewrite and the case it must ignore.

```js title="transforms/__tests__/rename-get-user.test.js"
const { defineInlineTest } = require('jscodeshift/dist/testUtils')
const transform = require('../rename-get-user')

defineInlineTest(transform, {}, 'const u = client.getUser(id)', 'const u = client.fetchUser(id)', 'renames the client call', { parser: 'tsx' })
defineInlineTest(transform, {}, 'other.getUser(id)', 'other.getUser(id)', 'leaves other receivers alone', { parser: 'tsx' })
```

Run it with `--dry --print` first. jscodeshift prints the transformed files and a summary of how many files were changed, unmodified, skipped, or failed, without writing anything.

## How Factory runs the JavaScript toolchain

Factory does not include a JavaScript migration mode or a source-to-source translator. It runs the tools above at fixed points and lets Droids work between them.

[Hooks](https://docs.factory.com/harness/hooks) run deterministic shell commands at lifecycle events. A `PostToolUse` hook runs after each edit, and exit code `2` sends its stderr back to the Droid as corrective feedback. This one lints every JavaScript or TypeScript file the Droid touches.

```json title=".factory/hooks.json"
{
  "PostToolUse": [
    {
      "matcher": "Edit|Create",
      "hooks": [
        {
          "type": "command",
          "command": "\"$FACTORY_PROJECT_DIR\"/.factory/hooks/eslint-check.sh",
          "timeout": 60
        }
      ]
    }
  ]
}
```

```bash title=".factory/hooks/eslint-check.sh"
#!/usr/bin/env bash
file=$(jq -r '.tool_input.file_path // empty')
[[ "$file" =~ \.(js|jsx|ts|tsx)$ ]] || exit 0
if ! npx eslint --max-warnings 0 "$file" >&2
then
  echo "ESLint reports problems in $file. Fix them before continuing." >&2
  exit 2
fi
```

A [skill](https://docs.factory.com/harness/skills) such as `.factory/skills/react-upgrade/SKILL.md` records the playbook, including which codemods run in which order, the target versions, and the commands that verify each step. AGENTS.md holds the exact `tsc`, lint, and test commands so headless runs use the same gate as interactive sessions.

For type repair across many files, Droid Exec follows the pattern in the [lint autofix recipe](https://docs.factory.com/droid-exec/recipes/lint-autofix), where a tool lists failing files, one `droid exec --auto low` run fixes each file, and the tool checks again. The recipe uses ESLint. This variant uses `tsc` output, and the `< /dev/null` keeps `droid exec` from reading the rest of the file list as its prompt.

```bash title="fix-type-errors.sh"
#!/usr/bin/env bash
npx tsc --noEmit --pretty false | grep -oE '^[^(]+\.tsx?' | sort -u |
  while read -r file
  do
    droid exec --auto low "Fix the TypeScript errors in $file. Keep runtime behavior the same. Do not add any, @ts-ignore, or @ts-expect-error." < /dev/null
  done
npx tsc --noEmit
```

Migrations that span many packages or repositories fit [Missions](https://docs.factory.com/missions/overview), which list brownfield migrations as a use case and validate work at each milestone.

The diagram shows the loop for one package.

Deterministic pass</span><br/><span style='color:#8A8380 !important'>framework codemods, jscodeshift, eslint --fix</span>"]
    B["<span style='color:#FAFAFA !important;font-weight:600'>Gate</span><br/><span style='color:#8A8380 !important'>tsc --noEmit and the test suite</span>"]
    C["<span style='color:#FAFAFA !important;font-weight:600'>Droid handles residue</span><br/><span style='color:#8A8380 !important'>types, uncovered changes, new codemods</span>"]
    D{"<span style='color:#FAFAFA !important;font-weight:600'>Rerun tools</span><br/><span style='color:#8A8380 !important'>jscodeshift --dry and eslint</span>"}
    E["<span style='color:#FAFAFA !important;font-weight:600'>Human review</span><br/><span style='color:#8A8380 !important'>tool commit and agent commit</span>"]
    A --> B --> C --> D
    D -- changes remain --> C
    D -- zero changes --> E
`}
/>

## Verifying JavaScript and TypeScript codemod output

The gate is deterministic from end to end. `tsc --noEmit` must pass with no new suppressions, ESLint must report no errors, and the test suite must pass. Where tests are thin, add characterization tests for the affected screens or modules before the codemods run.

Then rerun each transform with `--dry`. A finished branch shows zero files that would change, which means no later edit reintroduced the old pattern.

Split the pull request into commits by origin. For Angular, `ng update --create-commits` already writes a separate commit for the update and for each migration. Codemod output goes in one commit with the exact commands in the description, so a reviewer can regenerate it. New transforms and their tests go in another. The Droid's manual edits go last. Reviewers can confirm the mechanical commit quickly and spend their time on the parts that involved judgment.

Measure the first package before scaling out. Count how many files the codemods changed, how many needed Droid edits, and how many review comments landed on each commit. A high share of manual edits in one pattern is the signal to write another transform before the next package.

## Frequently asked questions

### Which codemod tools are used for JavaScript and TypeScript?

jscodeshift runs AST transforms across a codebase and ships test helpers. ts-morph wraps the TypeScript compiler API for transforms that need type information. Framework projects publish their own codemods, including Angular migrations through ng update, the React codemod repository, and npx @next/codemod for Next.js.

### How do you migrate a JavaScript codebase to TypeScript?

Rename files and add a tsconfig mechanically, using a tool such as Airbnb ts-migrate or a custom script, then replace the resulting any types and suppression comments in small batches with tsc --noEmit as the gate. The type repair is where a Droid does most of the work.

### What is left for a Droid after the codemods run?

Type errors the codemod exposed, framework changes with no published codemod, behavior choices such as new rendering or caching defaults, failing tests, and repeated patterns that deserve a new jscodeshift or ts-morph transform with its own tests.

### How do you prove a codemod run is complete?

Run tsc --noEmit, ESLint, and the test suite, then rerun each transform with jscodeshift --dry. The dry run should report zero files that would change.

### Does Factory have a built-in JavaScript migration mode?

No. Factory runs the JavaScript and TypeScript tools through hooks, skills, AGENTS.md, Droid Exec, and Missions. Droids also write and test new codemods, which then run deterministically like any other tool.

## Further reading

- [Deterministic code modernization with Factory](https://factory.com/articles/deterministic-code-modernization)
- [Python modernization with Ruff and LibCST](https://factory.com/articles/python-modernization-codemods)
- [Runtime upgrades with coding agents](https://factory.com/articles/coding-agent-runtime-upgrades)
- [Droid Exec docs](https://docs.factory.com/droid-exec/overview)
