When Manual Refactoring Stops Scaling

Library maintainers face a familiar problem: once an API ships and gains wide adoption, new usage patterns inevitably expose design limitations. Extending a function signature or adding parameters to fix an edge case sounds straightforward—until you consider the thousands of teams dependent on your code. A simple find-and-replace might work for small changes, but sed and awk quickly become inadequate for complex modifications across unfamiliar codebases you don't control.

The conventional answer—announce a breaking change, release a new major version, and let users migrate on their own schedule—has real costs. Large-scale transitions like React's move from class components to hooks took years for many teams to complete. Meanwhile, maintainers shoulder the burden of supporting multiple legacy versions, and users grow wary of upgrading when changes feel disruptive and risky.

The alternative is to ship a migration tool alongside the new release: an automated script that handles the refactoring for your users, renaming functions, reordering parameters, and cleaning up outdated patterns without manual effort. React and Next.js have already adopted this approach to ease version transitions, such as migrating away from the old Context API.

Codemods: AST-Based Automation

A codemod (code modification) is an automated transformation script built on Abstract Syntax Tree (AST) manipulation. Originally developed at Facebook to manage refactoring across the growing React codebase, codemods apply consistent changes across every file in a project, eliminating the inconsistency and errors of manual updates across thousands of files.

The process reduces to three mechanical steps:

  1. Parse: convert source code into an AST, a tree representation of every syntactic element.
  2. Modify: traverse and transform the tree—renaming a function, swapping parameter order, removing deprecated calls.
  3. Rewrite: serialize the modified tree back into source code.

This is conceptually the same machinery your IDE uses for refactorings like Extract Function, Rename Variable, or Inline Function—operations that require understanding scope and resolving symbol collisions before applying changes. Codemods bring that precision to programmatic, large-scale transformations.

Practical tools for writing codemods include jscodeshift, hypermod.io, and codemod.com. The examples in this discussion cover two realistic scenarios: removing obsolete feature toggles and refactoring React component hierarchies. Both illustrate a key practice for managing complexity: breaking a large transformation into smaller, independently testable codemods, then composing them.

Beyond individual refactoring tasks, codemods give library authors a release strategy that reduces friction for users and long-term maintenance burden for themselves. Instead of asking adopters to figure out breaking changes on their own, you hand them a script that performs the migration in a verified, repeatable way.

Automating Toggle Cleanup with jscodeshift

When it’s time to remove a feature toggle that has shipped, the change is often mechanical but spread across many files: find every reference to the toggle, check which feature it gates, and strip out the dead branches. That’s exactly the kind of repetitive edit a codemod can handle. With jscodeshift—a toolkit maintained by Facebook—you can traverse the abstract syntax tree (AST), locate the relevant nodes, and apply surgical transformations across an entire repository.

A typical toggle cleanup starts with code like this:

const data = featureToggle('feature-new-product-list') ? { name: 'Product' } : undefined;

Once the feature is fully released, the goal is to reduce it to:

const data = { name: 'Product' };

The codemod must find every instance of featureToggle, confirm that the argument is 'feature-new-product-list', and remove the conditional wrapper—while leaving any other toggles (for example, feature-search-result-refinement) completely intact. That requires understanding the structure of the code, not just matching text.

Reading the AST and Writing Tests First

Before writing the transform, it helps to visualize how the code parses. Tools like AST Explorer show the node hierarchy: the variable data is assigned via a ConditionalExpression, whose test calls featureToggle('feature-new-product-list'). The consequent branch assigns { name: 'Product' }; the alternate assigns undefined.

With a clear input-output pair, the safest approach is to write tests first. jscodeshift integrates with jest, and defineInlineTest lets you specify the input, expected output, and a description of the case:

const transform = require("../remove-feature-new-product-list");

defineInlineTest(
  transform,
  {},
  `
  const data = featureToggle('feature-new-product-list') ? { name: 'Product' } : undefined;
  `,
  `
  const data = { name: 'Product' };
  `,
  "delete the toggle feature-new-product-list in conditional operator"
);

That test will fail initially because the transform doesn’t exist yet. A negative-case test—one that verifies other feature toggles remain unchanged—gets added alongside it:

defineInlineTest(
  transform,
  {},
  `
  const data = featureToggle('feature-search-result-refinement') ? { name: 'Product' } : undefined;
  `,
  `
  const data = featureToggle('feature-search-result-refinement') ? { name: 'Product' } : undefined;
  `,
  "do not change other feature toggles"
);

Building the Transform

A transform file exports a function that receives the source as a jscodeshift API object (file is the source, api contains the helpers). The basic skeleton reads the file into a tree, applies modifications, and converts back to source:

module.exports = function(fileInfo, api, options) {
  const j = api.jscodeshift;
  const root = j(fileInfo.source);

  // manipulate the tree nodes here

  return root.toSource();
};

From that base, the transform needs to:

  1. Find all ConditionalExpression nodes.
  2. Check that the test calls featureToggle('feature-new-product-list').
  3. Replace the whole conditional with its consequent branch.

In code, that looks like this:

module.exports = function (fileInfo, api, options) {
  const j = api.jscodeshift;
  const root = j(fileInfo.source);

  // Find ConditionalExpression where the test is featureToggle('feature-new-product-list')
  root
    .find(j.ConditionalExpression, {
      test: {
        callee: { name: "featureToggle" },
        arguments: [{ value: "feature-new-product-list" }],
      },
    })
    .forEach((path) => {
      // Replace the ConditionalExpression with the 'consequent'
      j(path).replaceWith(path.node.consequent);
    });

  return root.toSource();
};

What the codemod does, in short:

  • Finds every ConditionalExpression whose test invokes the specific toggle function with the matching string.
  • Replaces that entire conditional with the consequent node—removing the toggle logic in one pass.

Real codebases will introduce variations—if-else statements, negated conditions like !featureToggle(...), and nested logic—so plan on adding more test cases to harden the transform before running it on the whole project.

Once the transform is ready, jscodeshift’s CLI applies it to the target directory and reports what changed:

$ jscodeshift -t transform-name src/

After reviewing the diff and running the functional test suite, commit the result and open a pull request just as you would for a manual change.

Refactoring Components Out of a Monolith

Codemods also pay off when you want to break apart coupled UI components. Consider a design-system Avatar that currently wraps itself in a Tooltip whenever a name prop is passed. Here is the current, coupled implementation:

import { Tooltip } from "@design-system/tooltip";

const Avatar = ({ name, image }: AvatarProps) => {
  if (name) {
    return (
      <Tooltip content={name}>
        <CircleImage image={image} />
      </Tooltip>
    );
  }

  return <CircleImage image={image} />;
};

The goal is to make Avatar purely presentational, letting consumers apply the Tooltip themselves when needed. The refactored Avatar would just render the image:

const Avatar = ({ image }: AvatarProps) => {
  return <CircleImage image={image} />;
};

Consumers would then write:

import { Tooltip } from "@design-system/tooltip";
import { Avatar } from "@design-system/avatar";

const UserProfile = () => {
  return (
    <Tooltip content="Juntao Qiu">
      <Avatar image="/juntao.qiu.avatar.png" />
    </Tooltip>
  );
};

With hundreds of usages across the codebase, doing this by hand is impractical. A codemod can do the mechanical work. The transformation breaks down into a small set of steps:

  • Locate every Avatar usage that has a name prop.
  • If absent, leave the node untouched.
  • If present:
  1. Build a new Tooltip element.
  2. Move the name prop onto the Tooltip.
  3. Remove the name prop from Avatar.
  4. Nest the original Avatar as a child of the Tooltip.
  5. Replace the original Avatar node in the tree.

The first batch of tests—which you should write before the implementation—ensures the transform only fires when the name prop is present:

defineInlineTest(
    { default: transform, parser: "tsx" },
    {},
    `
    <Avatar name="Juntao Qiu" image="/juntao.qiu.avatar.png" />
    `,
    `
    <Tooltip content="Juntao Qiu">
      <Avatar image="/juntao.qiu.avatar.png" />
    </Tooltip>
    `,
    "wrap avatar with tooltip when name is provided"
  );

Finding every Avatar that has a name attribute uses the same search API as the toggle example:

root
  .find(j.JSXElement, {
    openingElement: { name: { name: "Avatar" } },
  })
  .forEach((path) => {
    // now we can handle each Avatar instance
  });

Once we locate a node with the prop, we branch and act:

root
  .find(j.JSXElement, {
    openingElement: { name: { name: "Avatar" } },
  })
  .forEach((path) => {
    const avatarNode = path.node;

    const nameAttr = avatarNode.openingElement.attributes.find(
      (attr) => attr.name.name === "name"
    );

    if (nameAttr) {
      const tooltipElement = createTooltipElement(
        nameAttr.value.value,
        avatarNode
      );
      j(path).replaceWith(tooltipElement);
    }
  });

The helper createTooltipElement uses jscodeshift’s JSX builders to construct a new Tooltip with the name prop, wraps the existing Avatar as its child, and swaps it in at the current path.

You can preview how tools like Hypermod let you iterate on the transform before running it against your repo—showing the original code on one side and the transformed output on the other. The result is that every Avatar usage now carries its own Tooltip, with the coupling removed across the whole codebase.

These examples cover simple toggles and UI decoupling, both of which are mechanical. The same workflow—parse, inspect, transform, test—applies to anything from renaming APIs to enforcing a new import style. But like any tool, codemods aren’t without their own complications. The next part of this discussion looks at the specific challenges you’ll run into and how to work around them.

Handling the Real-World Cases Codemods Miss

Writing codemods that only cover the "happy path" rarely survives contact with a real codebase. Developers routinely alias imports to avoid name collisions, or restructure logic in ways a naive text search can't catch. If someone imports two different Avatar components, a plain search for the name will match the wrong one. The codemod must instead detect the alias and apply changes using the locally-bound identifier. The same problem appears with Tooltip imports — the codemod cannot assume the component named Tooltip is the one it's looking for.

Variations in usage make the edge cases hard to enumerate. A feature toggle might be invoked directly inside an if statement, assigned to a variable before use, combined with other conditions, or negated. No transformation built purely from anticipated cases is safe; without thorough testing, you risk breaking code you never intended to touch.

Source Graphs and Tests Before Transforms

One effective strategy is to pair codemods with codebase intelligence. In a design system rewrite at Atlassian, the team first searched the internal source graph to learn how components were actually used — whether they were imported under aliases or whether certain public props appeared frequently. With that data, they wrote test cases upfront to cover the majority of use cases, and only then developed the codemod itself.

For the uncommon cases that couldn't be automated confidently, they inserted comments or TODOs at the call sites, leaving a handful of manual fixes for developers running the script. This approach made version upgrades practical without over-engineering the transform.

Reducing Edge Cases With Linters

Edge cases multiply when you don't control the codebase — particularly with external dependencies. Codemods need careful supervision and result review. However, existing standardization tools can shrink the problem space. A linter that enforces a consistent style reduces the variations a codemod must handle. For instance, rules that ban nested ternary operators or require named exports over default exports make transformations more predictable.

Breaking a complex change into smaller, focused transformations also helps. Composing smaller codemods makes difficult changes more tractable.

Composing Codemods Instead of Writing Monoliths

In the feature toggle removal example, removing a toggle named feature-convert-new is only one task. After the toggle logic is gone, the code still needs cleanup: the now-unused convertOld function must be deleted, and the unused featureToggle import removed. A single monolithic codemod could handle everything in one pass, but a more maintainable path treats codemod logic like product code — split into smaller, independent pieces that are individually testable and reusable.

A practical decomposition might look like:

  • A transformation to remove a specific feature toggle.
  • A transformation to clean up unused imports.
  • A transformation to remove unused function declarations.

Composed together, these form a pipeline: first remove the toggle, then the import, then the obsolete function. Different transforms can be extracted and reordered as needed for different outcomes.

The createTransformer function implements this composition as a higher-order function. It accepts a list of smaller transform functions, applies each in sequence to the root AST, and writes the modified tree back to source code. This lets you define a transform that inlines expressions like assigning a toggle call to a variable, so later transforms no longer need to handle that variant. The inlined expression becomes the exact code the subsequent transform expects.

Accumulating a library of small, reusable transforms speeds up future migrations. After converting one package — like a button component — you might have utilities for adding comments at function starts, removing deprecated props, or creating aliases when a package is already imported. Each transform works independently or combines with others. Since they're standalone, you can rework one for performance — reducing node-finding passes, for example — without affecting composed transforms, provided test coverage is solid.

Applying the Pattern Beyond JavaScript

Codemods aren't limited to JavaScript and JSX. Java offers similar automation through libraries like JavaParser, which manipulates the AST to refactor code. JavaParser is useful for breaking API changes or structured rewrites in large Java codebases.

For a class like FeatureToggleExample.java that checks feature-convert-new and branches, a visitor can locate if statements calling FeatureToggle.isEnabled and replace each statement with its true branch — the equivalent of the JavaScript feature-toggle codemod. A FeatureToggleVisitor walks the AST, finds the matching statements, and substitutes the branch body.

JavaParser's visitor pattern also handles unused-code cleanup. An UnusedMethodRemover visitor tracks which methods are called during traversal. After visiting the whole tree, it checks each method declaration; any method that isn't called and isn't main gets queued for removal. Once traversal completes, the visitor deletes the unused declarations from the AST. Because each visitor is a unit of transformation, they can be chained and applied to the codebase sequentially.

OpenRewrite's Semantic Advantage

For Java, OpenRewrite offers an alternative approach based on Lossless Semantic Trees (LSTs). Unlike traditional ASTs used by JavaParser or jscodeshift, which emphasize syntactic structure, LSTs capture both syntax and semantic meaning. This richer representation supports more accurate and sophisticated transformations.

OpenRewrite ships with an ecosystem of open-source refactoring recipes for framework migrations, security fixes, and style consistency. Instead of writing custom scripts, developers can apply these standardized transformations across large codebases directly. When custom logic is needed, OpenRewrite supports creating and distributing your own recipes. The project is widely adopted in the Java community and continues to expand into other languages.

The core difference is structural: OpenRewrite's LSTs retain semantic detail that standard ASTs miss, while JavaParser and jscodeshift rely on the syntactic tree alone. That semantic depth, combined with a large recipe library, reduces the need to build codemods from scratch.

Alternatives to jscodeshift and OpenRewrite

jscodeshift and OpenRewrite cover the bulk of codemod work, but two other platforms are worth knowing about if you want to shorten the distance from idea to merged change.

Hypermod

Hypermod pairs codemod generation with AI authoring. Instead of hand-writing AST traversal logic, you describe the transformation you want in natural language and Hypermod produces the corresponding jscodeshift codemod. That lowers the barrier for developers who haven't worked directly with AST manipulation.

Beyond authoring, Hypermod handles the rest of the lifecycle. You can compose and test a codemod in the browser, then deploy it against any repository connected to the platform. Hypermod runs the transformation and opens a pull request with the results, so the path from codemod draft to reviewed and merged code is a single workflow.

Codemod.com

Codemod.com takes a community-driven approach. Developers publish codemods they have written and search for ones that already solve a given migration or refactoring problem. If you face a common API change, there is a decent chance someone has already built the codemod for it. Reusing a pre-built codemod beats writing one from scratch, and publishing your own helps the next team that hits the same transformation.

The Bottom Line on Codemods

Codemods exist to make large-scale code changes manageable. Automating transformations with jscodeshift, OpenRewrite, Hypermod, or similar tools turns what would be weeks of manual edits into a repeatable, reviewable process. That applies whether you are updating a single syntax detail or replacing a component across an entire codebase.

The catch is edge cases. Codemods operate on the assumption that your code follows recognizable patterns, and that assumption breaks down in diverse or publicly shared codebases. Inconsistent formatting, import aliases, and unexpected structures all slip past an automated rule. Handling those cases demands deliberate planning, thorough tests, and occasionally manual fixes to catch what the codemod misses.

The way to make codemods work at scale is to keep transformations small and incremental. Split a large migration into discrete, testable steps rather than one ambitious rewrite that tries to handle every variation at once. Pair that with code standardization tools where possible. Codemods are most effective when they are designed with a clear understanding of both what they can automate and where their limits sit.