Modernization
Enterprise AI
Modernizing JavaScript and TypeScript with codemods
November 14, 2026 - 6 minute read
Modernization
Enterprise AI
November 14, 2026 - 6 minute read
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.
Framework teams publish codemods for their own breaking changes, and those should run first. Angular's ng 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 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. 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 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 applies rule fixes with --fix and previews them with --fix-dry-run. typescript-eslint's 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 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.
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.
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.
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.
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.
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.
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 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.
{
"PostToolUse": [
{
"matcher": "Edit|Create",
"hooks": [
{
"type": "command",
"command": "\"$FACTORY_PROJECT_DIR\"/.factory/hooks/eslint-check.sh",
"timeout": 60
}
]
}
]
}#!/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
fiA skill 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, 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.
#!/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 --noEmitMigrations that span many packages or repositories fit Missions, which list brownfield migrations as a use case and validate work at each milestone.
The diagram shows the loop for one package.
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.
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.
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.
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.
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.
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.
Java modernization with OpenRewrite works as a loop. Recipes make the mechanical changes, Droids repair what the recipes leave and write new ones, and the compiler, tests, and a clean dry run verify every step. How to set it up with Factory.
PHP modernization works best when Rector performs the mechanical upgrade, PHPStan and the test suite gate every step, and Factory Droids handle the residue and write new Rector rules. A practical setup with hooks, skills, and Droid Exec.
COBOL modernization works when every step is checked by something deterministic. Compile with GnuCOBOL, inventory with a real parser, capture golden files from batch inputs, and let Droids handle the residue that translation tools leave behind.
Start building