cf.completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Working with ASTs

Use JavaScript ASTs like a tool author: parse with Acorn, read nodes and positions, walk the tree, change code safely, and compare Babel, SWC and Oxc.

By the end, you can
  • 01
    Read a real treeParse code with Acorn, and find any part of it by its node type, fields and positions.
  • 02
    Walk and change itWrite a walker and a visitor, and change code by editing the text at node positions.
  • 03
    Pick the right parserKnow which format Acorn, Babel, SWC and Oxc give, and use AST Explorer to find the shape you need.

Parse, walk, change, print

An abstract syntax tree, or AST, is your code turned into nested objects, one for each part. The parsing lesson showed how a parser builds it. This lesson uses it the way tool authors do: to find code, check it and change it.

Parse one line with AcornPop out in the code editor (opens in a new tab)JavaScript
import { parse } from "acorn";

const code = "const total = price * 2;";
const ast = parse(code, { ecmaVersion: "latest" });
const declaration = ast.body[0];
console.log(declaration.type, declaration.kind);
console.log(declaration.declarations[0].id.name);
console.log(declaration.declarations[0].init.type, declaration.declarations[0].init.operator);

Line 4 parses the code into a tree. ecmaVersion: "latest" tells Acorn to accept every finished feature. Line 5 takes the first statement in the program's body.

Line 6 prints VariableDeclaration const: the node's type, and the keyword it used. Line 7 prints the name, total. Line 8 prints BinaryExpression *: the value is a calculation with *.

Four function cards in a row joined by arrows: parse, walk, change, print. Above the first: your code. Above the last: new code. Under them: text to tree, visit nodes, edit nodes, back to text.your codenew codeparsetext to treewalkvisit nodeschangeedit nodesprintback to text
Every tool in this lesson follows the same four steps. A linter stops after walk: it only reports. A codemod or a compiler goes all the way round.
Real-life analogyDiagramming a sentence

In a grammar class, you might draw a sentence as a diagram: the subject here, the verb there, and the words that describe each. Once you have the diagram, finding every verb is easy, even if the sentence is long. Tools do the same with code: they work on the diagram, not on the letters.

In real life: The sentence
In JavaScript: Your source code
In real life: The diagram of the sentence
In JavaScript: The AST
In real life: Finding every verb
In JavaScript: Walking the tree for CallExpression nodes
In real life: Swapping one word, keeping the rest
In JavaScript: Editing the text of one node
The main idea

Tools parse code into a tree, walk the tree to find what they need, change it, and print code back. Working on the tree is exact: a string that says console.log is never mistaken for a real call.

Reading a node: type, fields and positions

Every node has a type, such as CallExpression. Its other fields hold its parts, and some of those are nodes too. Acorn also gives each node start and end: where its text begins and ends in the code.

Where is the console.log call?Pop out in the code editor (opens in a new tab)JavaScript
import { parse } from "acorn";

const code = 'greet("Ada");\nconsole.log("hi");';
const ast = parse(code, { ecmaVersion: "latest", locations: true });
const call = ast.body[1].expression;
console.log(call.type, call.start, call.end);
console.log(code.slice(call.start, call.end));
console.log(call.loc.start.line, call.loc.start.column);

Line 3 holds two lines of code. Line 4 parses them with locations: true, which adds line and column numbers. Line 5 takes the second statement, then its expression: an ExpressionStatement wraps every expression used as a statement.

Line 6 prints CallExpression 14 31. Counting from 0, the call starts at character 14, and end is the position just after its last character. So line 7, code.slice(call.start, call.end), prints exactly console.log("hi"). Line 8 prints 2 0: line 2, column 0. Lines count from 1 and columns from 0.

A card CallExpression 14 to 31 at the top. An arrow labeled callee goes down to MemberExpression 14 to 25, over the code console.log. An arrow labeled arguments 0 goes down to Literal 26 to 30, over the code quote hi quote.CallExpression 14–31calleearguments[0]MemberExpression 14–25Literal 26–30console.log"hi"
Each node knows where its text starts and ends. The call covers characters 14 to 31; its callee covers 14 to 25, and its first argument 26 to 30. code.slice(start, end) gives back each node's text.

Positions are what let a tool point at your code: an editor's red underline, a linter's file.js:2:1, and the edits later in this lesson all come from them.

Parsers: Acorn, Babel, SWC and Oxc

ESTree is a shared description of what a JavaScript AST looks like. Not every parser follows it. Read each row of the table as: "this parser is written in this language, gives this format, and is used by these tools."

Parsers you will meet
ParserWritten inTree formatUsed by
AcornJavaScriptESTreeESLint, through Espree; this site's code editor
@babel/parserJavaScriptBabel's own, close to ESTreeBabel; Prettier, for .js files
SWCRustSWC's ownNext.js; Rollup's parser
OxcRustESTree, for JavaScriptOxlint; Rolldown
TypeScriptTypeScriptTypeScript's owntsc and code editors

Rust parsers such as SWC and Oxc are much faster on big projects, which is why newer tools use them. But a different format matters as soon as you write code that checks node types.

A check written for one formatPop out in the code editor (opens in a new tab)JavaScript
// A check written for ESTree, the format Acorn gives.
function isString(node) {
  return node.type === "Literal" && typeof node.value === "string";
}

const fromAcorn = { type: "Literal", value: "tea", raw: '"tea"' };
const fromBabel = { type: "StringLiteral", value: "tea" };
console.log(isString(fromAcorn), isString(fromBabel));

Line 3 accepts a node only if its type is Literal, ESTree's name for a string. Line 6 is a string node from Acorn, and line 7 is the same string from @babel/parser. Line 8 prints true false: the check misses Babel's StringLiteral.

A card with the string tea points to three function cards: Acorn and Oxc, @babel/parser, and SWC. They give Literal, labeled ESTree; StringLiteral, labeled Babel's format; and StringLiteral, labeled SWC's format."tea"Acorn, Oxc@babel/parserSWCLiteralStringLiteralStringLiteralESTreeBabel's formatSWC's format
The same "tea" becomes a Literal in ESTree, the format Acorn and Oxc give, but a StringLiteral in Babel's and SWC's formats. A check written for one format can miss nodes from another.

Read each row of the next table as: "for this part of the code, ESTree, Babel and SWC use these names."

The same parts in three formats
Part of the codeESTree (Acorn, Oxc)BabelSWC
A string such as "tea"LiteralStringLiteralStringLiteral
A number such as 2LiteralNumericLiteralNumericLiteral
The name in an Identifiernamenamevalue
Where a node isstart and endstart and endspan

So write your tool for the format your parser gives. Babel has an estree plugin that makes @babel/parser give ESTree nodes, for tools that expect them.

Which format does each parser give?
  • Acorn
  • Espree, ESLint's parser
  • Oxc, for a .js file
  • @babel/parser
  • @babel/parser with its estree plugin
  • Prettier's parser for .js files
  • SWC
  • The TypeScript compiler
Try it yourself
0 of 8 correct

Put each parser with the format of tree it gives.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Visiting every node

To find something, a tool visits every node. A walker is a function that does that: it looks at a node, then at every node in its fields, and so on down. You can write one in a few lines.

A walker that prints the treePop out in the code editor (opens in a new tab)JavaScript
import { parse } from "acorn";

function walk(node, depth = 0) {
  console.log("  ".repeat(depth) + node.type);
  for (const value of Object.values(node)) {
    const children = Array.isArray(value) ? value : [value];
    for (const child of children) {
      if (child && typeof child.type === "string") walk(child, depth + 1);
    }
  }
}

walk(parse("total(price, 2);", { ecmaVersion: "latest" }));

Line 4 prints the node's type, indented by its depth. Lines 5 and 6 go through every field, and treat a single value like a list of one. Line 8 walks any value that has a type: that is how it knows a field holds a node.

Line 13 starts the walk. It prints Program, then ExpressionStatement, then CallExpression, then three nodes for total, price and 2, each indented one level more than its parent.

Step through a walk of total(price, 2)
Step 0 of 11Ready
Your turn: follow the blue line

A replay of the example code, not a view inside a JavaScript engine. The walk runs for real on Acorn's tree.

Running in
  1. script
Next: line 13
import { parse } from "acorn"; function walk(node, depth = 0) {  console.log("  ".repeat(depth) + node.type);  for (const value of Object.values(node)) {    const children = Array.isArray(value) ? value : [value];    for (const child of children) {      if (child && typeof child.type === "string") walk(child, depth + 1);    }  }} 
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Press Step and watch line 4 print each node before line 8 goes into its children. This replays the example code; it isn't a view inside the JavaScript engine.

Most tools don't walk by hand. They take a visitor: an object with one function for each node type you care about. acorn-walk, Babel and ESLint rules all work this way.

A visitor that lists functionsPop out in the code editor (opens in a new tab)JavaScript
import { parse } from "acorn";
import { simple } from "acorn-walk";

const code = [
  "function add(a, b) { return a + b; }",
  "const double = (n) => n * 2;",
  "setTimeout(function () {}, 10);",
].join("\n");
const ast = parse(code, { ecmaVersion: "latest" });
const found = [];
simple(ast, {
  FunctionDeclaration(node) {
    found.push(node.id.name + ": " + node.params.length);
  },
  ArrowFunctionExpression(node) {
    found.push("arrow: " + node.params.length);
  },
  FunctionExpression(node) {
    found.push("function: " + node.params.length);
  },
});
console.log(found.join(", "));

Lines 4 to 8 hold three kinds of function. acorn-walk's simple, on line 11, calls the function named after each node's type. Lines 12, 15 and 18 handle the three kinds, and line 22 prints add: 2, arrow: 1, function: 0: each function and how many parameters it has.

Changing code at node positions

There are two ways to change code with an AST. You can change the tree and print all of it back as code, or you can use node positions to edit just those parts of the original text. Printing a whole tree often loses comments and spacing. Editing at positions keeps everything else exactly as it was.

Turn var into letPop out in the code editor (opens in a new tab)JavaScript
import { parse } from "acorn";
import { simple } from "acorn-walk";

const code = "var cups = 2; // two cups\nvar tea = cups * 3;\nconsole.log(tea);";
const ast = parse(code, { ecmaVersion: "latest" });
const edits = [];
simple(ast, {
  VariableDeclaration(node) {
    if (node.kind === "var") edits.push({ start: node.start, end: node.start + 3, text: "let" });
  },
});
let output = code;
for (const edit of edits.sort((a, b) => b.start - a.start)) {
  output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
}
console.log(output);

Line 9 finds each var declaration and plans an edit: replace its first 3 characters with let. Lines 13 and 14 apply the edits to the text. Line 16 prints the code with let twice, and the comment // two cups is still there.

Line 13 sorts the edits so the last one comes first. That order matters whenever an edit changes the length of the text.

Two renames, in two ordersPop out in the code editor (opens in a new tab)JavaScript
const code = "let cups = 2;\nconsole.log(cups);";
const edits = [{ start: 4, end: 8 }, { start: 26, end: 30 }];

function apply(list) {
  let text = code;
  for (const edit of list) text = text.slice(0, edit.start) + "cupCount" + text.slice(edit.end);
  return text;
}
console.log(apply(edits));
console.log(apply([...edits].reverse()));

Line 2 lists the positions of both cups. Line 9 applies them from the start: the first rename makes the text 4 characters longer, so position 26 now points at log(, and the second line prints console.cupCountcups);.

Line 10 applies them from the end. The later edit can't move the earlier position, so it prints the right code. Libraries such as magic-string, used by Rollup and Vite, keep track of this for you.

Two rows. Top row: edit 4, then 26, gives console.cupCountcups, labeled wrong. Bottom row: edit 26, then 4, gives console.log of cupCount, labeled right. Under both: edit from the end, so earlier positions don't move.edit 4, then 26console.cupCountcups);wrongedit 26, then 4console.log(cupCount);rightedit from the end, so earlier positions don't move
The first rename makes the text 4 characters longer, so position 26 now points at log(. Editing from the end leaves every earlier position where it was.

One more trap: a name is not a variable. Renaming every Identifier called cups changes far more than one variable.

Every Identifier named cupsPop out in the code editor (opens in a new tab)JavaScript
import { parse } from "acorn";

function walk(node, visit) {
  visit(node);
  for (const value of Object.values(node)) {
    for (const child of Array.isArray(value) ? value : [value]) {
      if (child && typeof child.type === "string") walk(child, visit);
    }
  }
}

const code = [
  "let cups = 2;",
  "const order = { cups: 5 };",
  "function pour(cups) { return cups; }",
  "console.log(cups, order.cups);",
].join("\n");
const ast = parse(code, { ecmaVersion: "latest", locations: true });
const lines = [];
walk(ast, (node) => {
  if (node.type === "Identifier" && node.name === "cups") lines.push(node.loc.start.line);
});
console.log(lines.length, lines.join(" "));

Lines 3 to 10 are a walker that calls visit on every node. Line 23 prints 6 1 2 3 3 4 4: six identifiers, on lines 1, 2, 3, 3, 4 and 4 of the code. Only two of them are the cups variable from line 1.

The one on line 2 is a property name, and so is the second one on line 4. The two on line 3 are a different variable, the parameter of pour. Real rename tools use scope analysis to tell them apart; the scope analysis lesson explains how.

Exploring trees with AST Explorer

Before you write a check, you need to know what the tree looks like. AST Explorer is a website for exactly that: paste code, pick a parser, and see its tree. The parsing lesson has a live explorer built with Acorn too.

Read each row of the table as: "do this in AST Explorer, and this happens."

What AST Explorer can do
Do thisAnd this happens
Pick a parserSee the tree that your tool's parser gives, such as acorn or @babel/parser.
Move the cursor in the codeThe tree highlights the node for that spot.
Hover over a nodeThe code it came from is highlighted.
Open the transform panelTry a Babel plugin or a jscodeshift codemod on the code, and see the output.
Save the snippetGet a link you can share with your team.

The usual way to work: type an example of the code you want to find, click on it, and copy the shape you see into your check.

A check copied from the tree's shapePop out in the code editor (opens in a new tab)JavaScript
import { parse } from "acorn";

function isConsoleLog(node) {
  return (
    node.type === "CallExpression" &&
    node.callee.type === "MemberExpression" &&
    !node.callee.computed &&
    node.callee.object.type === "Identifier" &&
    node.callee.object.name === "console" &&
    node.callee.property.name === "log"
  );
}

for (const line of ["console.log(1);", "logger.log(1);", "console.error(1);"]) {
  const call = parse(line, { ecmaVersion: "latest" }).body[0].expression;
  console.log(line, isConsoleLog(call));
}

Lines 5 to 10 check the shape AST Explorer shows for console.log(1): a CallExpression whose callee is a MemberExpression, with console before the dot and log after it. Line 7 rules out console["log"], where the name is computed.

Lines 14 to 16 try three lines. Only console.log(1); prints true: logger.log has the wrong object, and console.error the wrong property.

Build small tools

Each tool in the playground is a few lines of the code from this lesson: parse with Acorn, visit with acorn-walk, and edit the text at node positions. Pick a tool, then change the code and watch the results change.

Playground: small tools on your code
What the tool found2 found
  • add: 2 parameters, line 2
  • double: 1 parameter, line 5
Try it yourself
List functions: 2 found.

Each function node, with a name from the function itself or from the const it is stored in.

Acorn parses your code in this browser, and acorn-walk visits the nodes. Your code is never run. Changes are made by editing the text at each node's position.

Try typing var in another line, or a second ==. Then break the code, for example by deleting a ): no tool can run until the code parses.

Some rules of thumb for your own tools:

  • Pick a parser that reads your files. Plain Acorn reads neither JSX nor TypeScript; @babel/parser, typescript-eslint's parser, SWC and Oxc read both.
  • Find the shape in AST Explorer first, with the same parser your tool uses.
  • Edit text at positions to keep comments and formatting, and apply edits from the end.
  • Check what you change: parse the output again, and run your tests.

Common mix-ups

Most mix-ups come from treating the tree as if it were the text, or one parser's tree as everyone's.

  • "Parentheses are nodes." No. They only decide the tree's shape. (tax + 1) is a BinaryExpression, with no node for the brackets.
  • "Renaming means changing every Identifier with that name." No. Property names and other variables can share the name.
  • "Every parser gives the same tree." No. Babel and SWC have their own formats.
  • "Parsing runs the code." No. A parser only reads the text. That is why tools can safely read code they don't trust.
  • "To change code, print the whole tree again." You can, but it often loses comments and spacing. Editing at positions keeps them.

The table below puts three of these side by side. Read each row as: "people often think this, but this is true."

What is true about ASTs
TopicWhat people thinkWhat is true
ParenthesesThey become nodes in the tree.They only decide the shape; (tax + 1) is just a BinaryExpression.
RenamingChange every Identifier with that name.Property names and other variables share the name. Use scope analysis.
FormatsEvery parser gives the same tree.Babel and SWC use their own node names, such as StringLiteral.

Practice exercises

Each exercise reads or changes a real tree. Guess first, then run it.

Exercise 1 · Warm-upWhere did the parentheses go?

What does line 5 print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
import { parse } from "acorn";

const ast = parse("price * (tax + 1);", { ecmaVersion: "latest" });
const top = ast.body[0].expression;
console.log(top.type, top.operator, top.right.type, top.right.operator);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upSlice a node's text

    What does line 3 print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const code = 'const greeting = "hi " + name;';
    const node = { type: "BinaryExpression", start: 17, end: 29 };
    console.log(code.slice(node.start, node.end));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind every console call

      This prints 1. Change isConsoleCall so it finds every console method, not only log. What does your program print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      import { parse } from "acorn";
      
      function isConsoleCall(node) {
        return (
          node.type === "CallExpression" &&
          node.callee.type === "MemberExpression" &&
          node.callee.object.name === "console" &&
          node.callee.property.name === "log"
        );
      }
      
      const lines = ["console.log(1);", "console.error(2);", "logger.log(3);", "console.warn(4);"];
      const found = lines.filter((line) => isConsoleCall(parse(line, { ecmaVersion: "latest" }).body[0].expression));
      console.log(found.length);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFix the edit order

        This prints let total costce * 2;. Change line 7 so the edits land on the right words. What does your program print?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const code = "let sum = price * 2;";
        const edits = [
          { start: 4, end: 7, text: "total" },
          { start: 10, end: 15, text: "cost" },
        ];
        let output = code;
        for (const edit of edits) {
          output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
        }
        console.log(output);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeWrite a == to === codemod

          Fill in the visitor on line 9 so it plans an edit for each == and !=. What is the first line your program prints?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          import { parse } from "acorn";
          import { simple } from "acorn-walk";
          
          const code = "if (count == 0) reset();\nif (name != null) greet(name);";
          const ast = parse(code, { ecmaVersion: "latest" });
          const edits = [];
          simple(ast, {
            BinaryExpression(node) {
              // Find == or != between node.left.end and node.right.start, and add an edit.
            },
          });
          let output = code;
          for (const edit of edits.sort((a, b) => b.start - a.start)) {
            output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
          }
          console.log(output);

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeName arrow functions too

            This prints only add. Add a visitor so it also prints the name of the arrow function stored in double. What does your program print?

            Starter codePop out in the code editor (opens in a new tab)JavaScript
            import { parse } from "acorn";
            import { simple } from "acorn-walk";
            
            const code = ["function add(a, b) { return a + b; }", "const double = (n) => n * 2;"].join("\n");
            const ast = parse(code, { ecmaVersion: "latest" });
            const names = [];
            simple(ast, {
              FunctionDeclaration(node) {
                names.push(node.id.name);
              },
            });
            console.log(names.join(" "));

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, picture the tree: which nodes there are, what is in their fields, and where their text is. Each answer explains why it's right or wrong.

              The working with ASTs quiz · 6 questionsScore: first tries count
              1. Question 1 of 6What do a node's start and end tell you?

                Choose an answer to see the explanation.

              2. Question 2 of 6How many nodes does this walker count?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const node = {
                  type: "MemberExpression",
                  object: { type: "Identifier", name: "console" },
                  property: { type: "Identifier", name: "log" },
                  computed: false,
                };
                function count(n) {
                  let total = 1;
                  for (const value of Object.values(n)) {
                    if (value && typeof value.type === "string") total += count(value);
                  }
                  return total;
                }
                console.log(count(node));

                Choose an answer to see the explanation.

              3. Question 3 of 6What does this print after two edits made from the start?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                let text = "a + b";
                text = text.slice(0, 0) + "first" + text.slice(1);
                text = text.slice(0, 4) + "second" + text.slice(5);
                console.log(text);

                Choose an answer to see the explanation.

              4. Question 4 of 6Why might a rule that checks node.type === "Literal" miss strings in code parsed by @babel/parser?

                Choose an answer to see the explanation.

              5. Question 5 of 6Which parser is written in Rust and gives ESTree for JavaScript?

                Choose an answer to see the explanation.

              6. Question 6 of 6Why do many tools change code by editing the text at node positions?

                Choose an answer to see the explanation.

              Key takeaways

              Once code is a tree, a few lines of JavaScript can find, check and change it exactly.

              • Tools parse code into an AST, walk it, change it, and print code back.
              • Every node has a type and fields; Acorn adds start and end positions.
              • Acorn and Oxc give ESTree. Babel and SWC have their own formats, so write checks for your parser's format.
              • A walker visits every node; a visitor gives you one function per node type.
              • Edit the text at node positions, from the end, to keep comments and formatting.
              • A name is not a variable: renaming needs scope analysis.

              Remember the one-liner.
              Parse to a tree, find nodes by their shape, and change the text at their positions.

              Coming next: Codemods and Babel plugins. They use these same steps to change code across a whole codebase.

              CompleteFrontend Clear concepts. Working examples.