DEV Community

Cover image for **8 AST Manipulation Techniques to Transform JavaScript Code Automatically**
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

**8 AST Manipulation Techniques to Transform JavaScript Code Automatically**

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

I remember the first time I tried to change thousands of JavaScript files by hand. I opened each file, found the patterns, and replaced them one by one. It took days. That’s when I discovered AST manipulation – a way to treat code as data and transform it automatically. Let me walk you through eight techniques I use regularly. I’ll keep everything simple, with lots of code you can copy and tweak.

Parsing JavaScript into an AST

Every transformation starts with parsing. You take a string of code and turn it into a tree structure called an Abstract Syntax Tree (AST). Each node in the tree represents a piece of code – a variable declaration, a function call, a literal value. I use @babel/parser because it supports modern syntax like JSX, TypeScript, and optional chaining.

Here’s how I parse any JavaScript source:

const parser = require('@babel/parser');

function parseCode(source, options = {}) {
  try {
    return parser.parse(source, {
      sourceType: 'unambiguous',
      plugins: [
        'jsx',
        'typescript',
        'decorators-legacy',
        'classProperties',
        'optionalChaining',
        'nullishCoalescingOperator',
        ...(options.plugins || [])
      ]
    });
  } catch (error) {
    console.error(`Parse error at line ${error.loc.line}:${error.loc.column}`);
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

If the code has a syntax error, the parser throws. I catch that error and print the exact line and column so I know where to fix things. That’s your foundation. Without a good parse, nothing else works.

Walking the Tree (Traversal)

Once you have an AST, you need to move through it and visit nodes. Think of it like walking through a family tree. You start at the root (the whole program) and go down into children. For each node, you can run a function – we call these “visitors.”

I wrote a simple depth‑first traversal that keeps track of the parent node. That parent reference is gold: it lets you replace or remove a node later.

function traverse(ast, visitors) {
  const stack = [{ node: ast, parent: null, key: null }];

  while (stack.length > 0) {
    const { node, parent, key } = stack.pop();
    const path = { node, parent, key, replaceWith, remove };

    if (visitors.enter) visitors.enter(path);
    const specificVisitor = visitors[node.type];
    if (specificVisitor) specificVisitor(path);

    for (const prop of Object.keys(node)) {
      if (prop === 'type' || prop === 'start' || prop === 'end') continue;
      const value = node[prop];
      if (Array.isArray(value)) {
        value.forEach((child, index) => {
          if (typeof child === 'object' && child !== null) {
            stack.push({ node: child, parent: node, key: { prop, index } });
          }
        });
      } else if (typeof value === 'object' && value !== null) {
        stack.push({ node: value, parent: node, key: prop });
      }
    }

    if (visitors.exit) visitors.exit(path);
  }

  function replaceWith(newNode) {
    const { prop, index } = this.key;
    if (index !== undefined) {
      this.parent[prop][index] = newNode;
    } else {
      this.parent[prop] = newNode;
    }
  }

  function remove() {
    const { prop, index } = this.key;
    if (index !== undefined) {
      this.parent[prop].splice(index, 1);
    } else {
      this.parent[prop] = null;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the engine that powers every transformation. You write visitors like FunctionDeclaration(path) { ... } and the traversal calls your code for every function in the file.

Writing a Codemod: CommonJS to ES Modules

One of the most useful codemods I ever built converts require calls into import statements. You know the drill – your old codebase uses const foo = require('bar') and you want import foo from 'bar'. Let’s automate it.

I parse the file, scan for CallExpression nodes where the callee is require with a string argument. Then I check the parent: if it’s a variable declarator, I can generate the import.

function requireToImport(code) {
  const ast = parseCode(code);
  const imports = [];
  let output = code;

  traverse(ast, {
    CallExpression(path) {
      if (path.node.callee.name === 'require' && 
          path.node.arguments.length === 1 &&
          t.isStringLiteral(path.node.arguments[0])) {
        const modulePath = path.node.arguments[0].value;
        const parent = path.parent;
        if (t.isVariableDeclarator(parent) && 
            parent.init === path.node) {
          const id = parent.id;
          let importStr;
          if (t.isObjectPattern(id)) {
            const specifiers = id.properties.map(prop => 
              `${prop.value.name} as ${prop.key.name}`
            ).join(', ');
            importStr = `import { ${specifiers} } from '${modulePath}';`;
          } else if (t.isIdentifier(id)) {
            importStr = `import ${id.name} from '${modulePath}';`;
          }
          imports.push({ importStr, start: path.node.start, end: path.node.end });
        }
      }
    }
  });

  // Replace from bottom to top to preserve positions
  imports.sort((a, b) => b.start - a.start);
  imports.forEach(({ importStr, start, end }) => {
    output = output.slice(0, start) + importStr + output.slice(end);
  });

  return output;
}
Enter fullscreen mode Exit fullscreen mode

I sort replacements in reverse order so earlier text positions don’t shift when I replace later ones. That’s a trick I learned the hard way.

Custom Linting Rules

Sometimes ESLint rules don’t cover what you need. Maybe you want to forbid foo && foo.bar and force people to write foo?.bar. You can build a tiny linter that walks the AST and checks patterns.

I write a function that returns an array of violations, each with a message and a source location. The visitor looks for LogicalExpression nodes where the operator is && and the right side is a member expression on the same object.

function lintOptionalChaining(source) {
  const ast = parseCode(source);
  const violations = [];

  traverse(ast, {
    LogicalExpression(path) {
      const node = path.node;
      if (node.operator === '&&' && 
          t.isMemberExpression(node.right) &&
          t.isIdentifier(node.right.object) &&
          node.right.object.name === node.left.name) {
        violations.push({
          message: 'Use optional chaining instead of logical AND guard',
          line: node.loc.start.line,
          column: node.loc.start.column,
          fix: () => {
            const memberExpr = node.right;
            memberExpr.optional = true;
            memberExpr.object = node.left;
            return memberExpr;
          }
        });
      }
    }
  });

  return violations;
}
Enter fullscreen mode Exit fullscreen mode

You can plug these violations into any reporting system. I often print them to the console with the file name and line number. The fix function returns a replacement node – that’s how you can automatically correct the pattern.

Transforming JSX into Function Calls

If you’re building a framework that doesn’t use React’s runtime, you might need to convert <div className="foo">Hello</div> into createElement('div', { className: 'foo' }, 'Hello'). I wrote a transformer that walks JSX elements and builds the equivalent call expression.

The trick is handling attributes, children, and spread attributes. Each attribute becomes a property in an object, and children become extra arguments.

function jsxToCreateElement(ast) {
  traverse(ast, {
    JSXElement(path) {
      const node = path.node;
      const tagName = t.isJSXIdentifier(node.openingElement.name) 
        ? node.openingElement.name.name 
        : t.stringLiteral(node.openingElement.name.name);

      const props = [];
      if (node.openingElement.attributes.length > 0) {
        const propObject = {};
        node.openingElement.attributes.forEach(attr => {
          if (t.isJSXAttribute(attr)) {
            const key = attr.name.name;
            let value;
            if (attr.value === null) {
              value = t.booleanLiteral(true);
            } else if (t.isJSXExpressionContainer(attr.value)) {
              value = attr.value.expression;
            } else {
              value = attr.value;
            }
            propObject[key] = value;
          } else if (t.isJSXSpreadAttribute(attr)) {
            props.push(t.spreadElement(attr.argument));
          }
        });
        props.push(t.objectExpression(
          Object.entries(propObject).map(([k, v]) => 
            t.objectProperty(t.identifier(k), v)
          )
        ));
      } else {
        props.push(t.nullLiteral());
      }

      const children = node.children
        .filter(child => !t.isJSXText(child) || child.value.trim())
        .map(child => {
          if (t.isJSXText(child)) return t.stringLiteral(child.value.trim());
          if (t.isJSXExpressionContainer(child)) return child.expression;
          return child;
        });

      path.replaceWith(
        t.callExpression(
          t.identifier('createElement'),
          [tagName, ...props, ...children]
        )
      );
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

After this, you can run the AST through a generator and get plain JavaScript with no JSX syntax.

Building a Simple Minifier

A minifier shrinks code by shortening variable names, removing whitespace, and eliminating dead code. I built one that collects all identifier names in a scope and renames them to single letters. The AST gives me perfect scope information – I find all VariableDeclarator and FunctionDeclaration nodes, grab their bound names, and assign short aliases.

function minify(ast) {
  const scopes = new Map();
  traverse(ast, {
    FunctionDeclaration(path) {
      const scope = [];
      path.node.params.forEach(param => {
        if (t.isIdentifier(param)) scope.push(param.name);
      });
      scopes.set(path.node, scope);
    },
    VariableDeclaration(path) {
      path.node.declarations.forEach(decl => {
        if (t.isIdentifier(decl.id)) {
          const parentScope = findParentScope(path);
          parentScope.push(decl.id.name);
        }
      });
    }
  });

  let counter = 0;
  const renameMap = new Map();
  const shortNames = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');

  scopes.forEach((vars) => {
    vars.forEach(name => {
      if (!renameMap.has(name)) {
        renameMap.set(name, shortNames[counter % shortNames.length] + 
          (counter >= shortNames.length ? Math.floor(counter / shortNames.length) : ''));
        counter++;
      }
    });
  });

  traverse(ast, {
    Identifier(path) {
      if (renameMap.has(path.node.name) && !path.isReferencedIdentifier()) {
        path.node.name = renameMap.get(path.node.name);
      }
    }
  });

  const { code: generated } = require('@babel/generator').default(ast, {
    retainLines: false,
    compact: true,
    comments: false,
    minified: true
  });

  return generated;
}
Enter fullscreen mode Exit fullscreen mode

This is just the beginning – you can also fold constants, remove console.log statements, and inline simple expressions.

Type Checking with AST

You don’t always need a full TypeScript compiler. Sometimes you just want to check that variables are used with the right type. I build a lightweight type checker that reads type annotations (like : string) and infers types from literals. Then it compares them when assignments happen.

function typeCheck(ast) {
  const types = new Map();

  traverse(ast, {
    VariableDeclarator(path) {
      if (path.node.id.typeAnnotation) {
        const type = path.node.id.typeAnnotation.typeAnnotation;
        types.set(path.node.id.name, resolveTypeAnnotation(type));
      }
    },
    FunctionDeclaration(path) {
      if (path.node.returnType) {
        const returnType = resolveTypeAnnotation(path.node.returnType);
        types.set(path.node.id.name + ':return', returnType);
      }
      path.node.params.forEach((param, i) => {
        if (param.typeAnnotation) {
          const paramType = resolveTypeAnnotation(param.typeAnnotation.typeAnnotation);
          types.set(param.name, paramType);
        }
      });
    }
  });

  const errors = [];

  traverse(ast, {
    AssignmentExpression(path) {
      const left = path.node.left;
      if (t.isIdentifier(left) && types.has(left.name)) {
        const expectedType = types.get(left.name);
        const actualType = inferType(path.node.right, types);
        if (!isTypeCompatible(actualType, expectedType)) {
          errors.push({
            message: `Type mismatch: expected ${expectedType}, got ${actualType}`,
            line: path.node.loc.start.line
          });
        }
      }
    }
  });

  return errors;

  function resolveTypeAnnotation(node) {
    if (t.isTSStringKeyword(node)) return 'string';
    if (t.isTSNumberKeyword(node)) return 'number';
    if (t.isTSBooleanKeyword(node)) return 'boolean';
    if (t.isTSArrayType(node)) return `Array<${resolveTypeAnnotation(node.elementType)}>`;
    return 'any';
  }

  function inferType(node) {
    if (t.isStringLiteral(node)) return 'string';
    if (t.isNumericLiteral(node)) return 'number';
    if (t.isBooleanLiteral(node)) return 'boolean';
    if (t.isIdentifier(node) && types.has(node.name)) return types.get(node.name);
    return 'any';
  }

  function isTypeCompatible(actual, expected) {
    if (expected === 'any' || actual === 'any') return true;
    return actual === expected;
  }
}
Enter fullscreen mode Exit fullscreen mode

This catches silly mistakes before you ever run the code. You can extend it to handle unions, arrays, and function signatures.

Creating a DSL Compiler

The most fun technique is building a tiny domain‑specific language compiler. You write your own syntax, parse it into a custom AST, then generate JavaScript. I made a simple “math DSL” where x = 5 + 3 becomes a JavaScript variable declaration.

function dslCompiler(dslSource) {
  const dslAst = parseDSL(dslSource);
  const jsAst = generateJSAST(dslAst);
  return require('@babel/generator').default(jsAst).code;
}

function parseDSL(source) {
  const lines = source.split('\n').filter(l => l.trim());
  const nodes = [];
  lines.forEach(line => {
    const match = line.match(/^(\w+)\s*=\s*(.+)$/);
    if (match) {
      nodes.push({ type: 'Assignment', name: match[1], expression: match[2] });
    }
  });
  return { type: 'Program', body: nodes };
}

function generateJSAST(dslAst) {
  const statements = [];
  dslAst.body.forEach(node => {
    if (node.type === 'Assignment') {
      statements.push(
        t.variableDeclaration('const', [
          t.variableDeclarator(
            t.identifier(node.name),
            t.stringLiteral(node.expression)
          )
        ])
      );
    }
  });
  return t.program(statements);
}
Enter fullscreen mode Exit fullscreen mode

You can expand this to handle arithmetic, conditionals, and loops. The AST is just a blueprint – you control every detail of the generated output.

Putting It All Together

Each of these techniques builds on the same idea: treat code as data. You parse it, walk the tree, change nodes, and generate new code. I’ve used these in real projects to migrate thousands of files, enforce team conventions, and even create custom build tools.

Start small. Pick one pattern you hate in your codebase and write a visitor to fix it. Then add another. Before you know it, you’ll have a whole suite of transformations that save you hours every week. The AST is your friend – it gives you total control over JavaScript without ever touching a regular expression.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)