Large-scale code changes using ASTs and jscodeshift

Migrating a codebase is often a complex undertaking, and I recently tackled a particularly interesting challenge: migrating a TypeScript medium-sized monorepo from CommonJS (CJS) to ECMAScript Modules (ESM).
A key part of this process involved updating module specifiers.

This post details how I performed this migration using a robust solution on top of Abstract Syntax Trees (ASTs) and jscodeshift.

The Challenge: Module Specifier Paths #

Here's the core issue:
CJS allows for some flexibility in how you specify module paths, but ESM doen't.

For example:

// for this extensionless module specifier in some TypeScript CJS module...
import * as constants from './constants';

// ...Node.js will look for these files:
import * as constants from './constants.js';
import * as constants from './constants.cjs';
import * as constants from './constants/index.js';
import * as constants from './constants/index.cjs';

However, ESM is much stricter.
It requires explicit file extensions:

// in ESM, this import will fail!
import * as constants from './constants';

// this works in ESM (and CJS)
import * as constants from './constants.js';

This difference means that a TypeScript CJS to ESM migration requires systematically updating these paths throughout the codebase.

Idea: Transform module specifiers via code #

What I needed to do was:

  • process each TypeScript file
  • detect all module specifiers in the file
  • for each module specifier
    • look into the file system to find out what the specifier actually points to (constants.js, constants/index.js, etc.)
    • change the specifier accordingly

For this kind of task it is best to implement a codemod by combining the power of ASTs and jscodeshift.

ASTs: A Deeper Look at Code Structure #

Abstract Syntax Trees (ASTs) provide a structural representation of code, allowing us to interact with individual elements (nodes) in a precise way.

There are mainly 3 types of ASTs in the TypeScript ecosystem:

  • ESTree
  • TypeScript AST
  • TSESTree

ESTree ("ECMAScript Tree", see github.com/estree/estree) is used by e.g. ESLint and Prettier, and it provides a "general purpose" AST.

The TypeScript AST is used by TypeScript and optimized for parsing incomplete code and typechecking.

TSESTree, generated by @typescript-eslint/parser, is an extension of ESTree that includes information from the TypeScript AST.

When in doubt, it is best to just use TSESTree because it covers TypeScript code and is supported by many tools.

Example: TSESTree of a TypeScript Variable Declaration #

For example, given this code...

const myVar: string = 'my-value';

...we can produce this TSESTree:

{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "myVar",
            "range": [6, 19],
            "typeAnnotation": {
              "type": "TSTypeAnnotation",
              "range": [11, 19],
              "typeAnnotation": {
                "type": "TSStringKeyword",
                "range": [13, 19]
              }
            }
          },
          "init": {
            "type": "Literal",
            "value": "my-value",
            "raw": "\"my-value\"",
            "range": [22, 32]
          },
          "range": [6, 32]
        }
      ],
      "kind": "const",
      "range": [0, 33]
    }
  ],
  "sourceType": "module",
  "range": [0, 34]
}

(you can explore this AST here)

It might look daunting at first, but important to know is that each node has

  • a type property that tells you what kind of node it is
  • a range property that tells you where in the source code the node is located
  • other properties that are specific to the node type

This structure allows us to navigate the code and find specific nodes to work with.
For example, to find all variable declarations, we would look for nodes with type: 'VariableDeclaration'.

But we don't have to do this manually - we can use jscodeshift to assist us!

jscodeshift: Transforming Code with ASTs #

jscodeshift is a powerful tool for working with ASTs.

It allows you to:

  • Parse code into an AST
  • Modify the AST
  • Generate code from the modified AST

It includes helpers to find and mutate AST nodes, and provides strongly-typed AST node types (e.g. j.ImportDeclaration).

This makes it perfect for automating code transformations.

It is used by:

  • Nuxt codemods (source)
  • Next.js codemods (source)
  • Prisma codemods (source)
  • and many more!

Putting things together #

Using what we learned, we can now write a script to update module specifiers of a file with only ~30 lines of code!

import fs from 'node:fs';
import j from 'jscodeshift';

// load code of source file and its AST
const text = await fs.promises.readFile('./constants.ts', 'utf8');
const programNode = j.withParser('tsx')(text);

// find all import declarations
const astNodesImportDeclarations = programNode.find(j.ImportDeclaration);

// extract the module specifiers themselves from the import declarations
const astNodesModuleSpecifiers = astNodesImportDeclarations.find(j.Literal);

// mutate them in-place
astNodesModuleSpecifiers.forEach((astPath) => {
  const astNode = astPath.node;

  const originalModuleSpecifier = astNode.value;
  /**
   * assume "resolveModuleSpecifierToFullPath" is a (synchronous) function which looks into the file system
   * to determine the full path
   * e.g. given './constants' it returns './constants.js`
   */
  const newModuleSpecifier = resolveModuleSpecifierToFullPath(originalModuleSpecifier);
  astNode.value = newModuleSpecifier;
});

// produce code from AST and write it back to the source file
const newText = programNode.toSource();
await fs.promises.writeFile('./constants.ts', newText, 'utf8');

This is the basic structure of the script I used to update all module specifiers.

Note that to make this complete, there are other types of nodes to capture:

  • export ... from <MODULE_SPECIFIER>
  • declare module <MODULE_SPECIFIER>
  • require(<MODULE_SPECIFIER>)
  • ...actually 9 ways to use module specifiers as of December 2024

I published the full codemod as NPM package here: @pkerschbaum/codemod-rewrite-module-specifiers-to-full-paths.
If you also need to transform all module specifiers of a TypeScript project to full paths, that NPM package is for you!

Did you like this blog post?

Great, then let's keep in touch! Follow me on Bluesky, I post about TypeScript, testing and web development in general - and of course about updates on my own blog posts.