Prerequisites
- Understanding ASTs — This article requires a basic understanding of how to inspect, traverse, and manipulate Abstract Syntax Trees. Codemod Studio has a built-in AST tree explorer that's great for visualizing node structures interactively.
- Codemod CLI installed
— You'll need the Codemod CLI available via
npx codemod. See the CLI docs if you haven't set it up yet.
Overview
Throughout this guide, we'll break down the thought process behind writing a real-world codemod—from identifying patterns and planning edge cases to implementing and testing the transform.
By the end, you will learn:
- How to write a codemod that solves a real-world problem using JSSG , Codemod's transformation engine.
- How to use ast-grep pattern matching and AST manipulation techniques.
- How to test and publish your codemod using the Codemod CLI.
Let's learn by example!
Problem
Before ES6, JavaScript codebases relied heavily on var for variable declarations. Due to var's scoping issues, let and const were introduced—but many codebases still haven't migrated.
Refactoring manually is tedious and error-prone. Simple find-and-replace won't work either, because there are edge cases where blindly swapping var for const would break your code.
This is a perfect job for a codemod. We'll build a no-vars transform that automatically converts var declarations to const or let wherever it's safe to do so.
Before:
var exampleVariable = "hello world";
After:
const exampleVariable = "hello world";
Planning Our Codemod
If you're new to codemod development, you might think this is as simple as: find all var declarations and replace them with const. But that would break your code in most cases.
Let's consider this snippet that covers several real-world patterns:
var notMutatedVar = "definitely not mutated";
var mutatedVar = "yep, i'm mutated";
for (var i = 0; i < 5; i++) {
mutatedVar = "foo";
var anotherInsideLoopVar = "should i be changed?";
}
for (var x of text) {
text += x + " ";
}
This covers the following cases:
varis declared and never mutatedvaris declared and mutatedvaris declared as a loop indexvaris declared inside a loop
Now, take a moment—which of these would break if we just replaced var with const?
Here's a summary of the patterns and their safe transforms:
#1 var is a loop index declaration
// Before
for (var i = 0; i < 5; i++)
// After
for (let i = 0; i < 5; i++)
The index is mutated (i++), so it must become let.
#2 var is a mutated variable
// Before
var x = 1;
x = 2;
// After
let x = 1;
x = 2;
Reassigned variables must become let.
#3 var is in a loop and mutated
// Before
for (var i = 0; i < 5; i++) {
var x = "foo";
x = "bar";
}
// After
for (let i = 0; i < 5; i++) {
let x = "foo";
x = "bar";
}
#4 Global or local non-mutated var
var x = "foo";
Safe to become const x = "foo".
#5 var is declared twice
var x;
var x;
Keep as var. Converting to let or const would cause a SyntaxError for duplicate declarations in the same scope.
#6 var is hoisted
x = 5;
var x;
Keep as var. The variable is used before its declaration, relying on var's hoisting behavior.
#7 var is in a loop and referenced inside a closure
for (var i = 0; i < 5; i++) {
var a = "hello";
function myFunction() {
a = "world";
return a;
}
}
Keep as var. Converting to let/const would change the closure behavior since let creates a new binding per iteration.
Test Cases
Now that we have a concrete list of patterns, let's prepare test fixtures. We'll use the Codemod CLI's built-in testing framework.
Test input (tests/no-vars/input.js):
var notMutatedVar = "definitely not mutated";
var mutatedVar = "yep, i'm mutated";
for (var i = 0; i < 5; i++) {
mutatedVar = "foo";
var anotherInsideLoopVar = "should i be changed?";
}
for (var x of text) {
text += x + " ";
}
Expected output (tests/no-vars/expected.js):
const notMutatedVar = "definitely not mutated";
let mutatedVar = "yep, i'm mutated";
for (let i = 0; i < 5; i++) {
mutatedVar = "foo";
const anotherInsideLoopVar = "should i be changed?";
}
for (const x of text) {
text += x + " ";
}
With this plan in mind, let's build the codemod.
Developing the Codemod
Scaffolding the Project
Start by scaffolding a new JSSG codemod package:
npx codemod init no-vars
Pick JavaScript ast-grep (JSSG) codemod when prompted. This gives you:
no-vars/
codemod.yaml
workflow.yaml
scripts/
codemod.ts
tests/
...
Now open scripts/codemod.ts—this is where we'll write our transform.
Understanding the Transform Signature
Every JSSG codemod exports a default transform function:
import type { Transform } from "codemod:ast-grep";
import type JS from "codemod:ast-grep/langs/javascript";
const transform: Transform<JS> = (root) => {
const rootNode = root.root();
// Your transformation logic here
return null; // return string for modified code, null for no changes
};
export default transform;
The function receives a parsed AST (root) and returns either a string (modified source code) or null (no changes).
Step 1: Detect Code Patterns
Our detection strategy:
- Find all
vardeclarations broadly - Filter out declarations that must stay as
var(hoisted, duplicated, closure-referenced) - Classify the remaining ones as
letorconst
1.1 Finding All var Declarations
We use ast-grep's pattern syntax to match all var declarations structurally. The pattern var $DECL matches any variable\_declaration node whose first token is the var keyword — no text filtering needed:
const varDeclarations = rootNode.findAll({
rule: { pattern: "var $DECL" },
});
1.2 Filtering Out Non-Transformable Cases
Now we need to identify var declarations that must stay as var. We'll write helper functions for each unsafe case.
Check if a variable is declared twice in the same scope:
function isDeclaredTwice(node: SgNode<JS>, rootNode: SgNode<JS>): boolean {
const declarators = node.findAll({
rule: { kind: "variable_declarator" },
});
for (const declarator of declarators) {
const nameNode = declarator.field("name");
if (!nameNode) continue;
const name = nameNode.text();
// Count declarations with the same name in the scope
const allDeclarations = rootNode.findAll({
rule: {
kind: "variable_declarator",
has: {
pattern: name,
},
},
});
if (allDeclarations.length > 1) return true;
}
return false;
}
Check if a variable is used before its declaration (hoisting):
function isHoisted(node: SgNode<JS>): boolean {
const declarators = node.findAll({
rule: { kind: "variable_declarator" },
});
for (const declarator of declarators) {
const nameNode = declarator.field("name");
if (!nameNode) continue;
const name = nameNode.text();
const declPos = node.range().start.index;
// Find usages of this variable before its declaration
const scope = findEnclosingScope(node);
if (!scope) continue;
const usages = scope.findAll({
rule: { kind: "identifier", pattern: name },
});
for (const usage of usages) {
if (usage.range().start.index < declPos) {
return true;
}
}
}
return false;
}
Check if a variable is inside a loop that contains closures:
When a loop contains closures (nested functions), converting var to let/const changes semantics because let creates a new binding per iteration. To be safe, we keep all var declarations as-is when the enclosing loop contains any closures.
function findEnclosingLoop(node: SgNode<JS>): SgNode<JS> | null {
let current = node.parent();
while (current) {
const kind = current.kind();
if (kind === "for_statement" || kind === "for_in_statement" || kind === "while_statement") {
return current;
}
// Stop at function boundaries
if (
kind === "function_declaration" ||
kind === "function_expression" ||
kind === "arrow_function" ||
kind === "program"
) {
return null;
}
current = current.parent();
}
return null;
}
function isInLoopWithClosure(node: SgNode<JS>): boolean {
const loop = findEnclosingLoop(node);
if (!loop) return false;
// If the loop contains any closures, keep all var declarations
// as var to preserve the original binding behavior.
const closures = loop.findAll({
rule: {
any: [
{ kind: "function_declaration" },
{ kind: "function_expression" },
{ kind: "arrow_function" },
],
},
});
return closures.length > 0;
}
Helper to find the enclosing scope:
function findEnclosingScope(node: SgNode<JS>): SgNode<JS> | null {
let current = node.parent();
while (current) {
const kind = current.kind();
if (
kind === "function_declaration" ||
kind === "function_expression" ||
kind === "arrow_function" ||
kind === "program"
) {
return current;
}
current = current.parent();
}
return null;
}
Step 2: Classify and Transform
Now we classify the remaining var declarations as either let or const:
- If the variable is
mutated
(reassigned or updated), use
let - If the variable is a
for-loop initializer
(e.g.,
for (var i = ...)), uselet - Otherwise, use
const
Check if a variable is mutated:
function isMutated(node: SgNode<JS>): boolean {
const declarators = node.findAll({
rule: { kind: "variable_declarator" },
});
const scope = findEnclosingScope(node);
if (!scope) return false;
for (const declarator of declarators) {
const nameNode = declarator.field("name");
if (!nameNode) continue;
const name = nameNode.text();
// Check for assignments (x = ...)
const assignments = scope.findAll({
rule: {
kind: "assignment_expression",
has: {
pattern: name,
},
},
});
if (assignments.length > 0) return true;
// Check for update expressions (x++, x--)
const updates = scope.findAll({
rule: {
kind: "update_expression",
has: { pattern: name },
},
});
if (updates.length > 0) return true;
}
return false;
}
Check if it's a for-loop initializer:
function isForLoopInit(node: SgNode<JS>): boolean {
const parent = node.parent();
return parent?.kind() === "for_statement";
}
Putting It All Together
Here's the complete transform:
View complete transform
import type { Transform } from "codemod:ast-grep";
import type JS from "codemod:ast-grep/langs/javascript";
import type { SgNode } from "codemod:ast-grep";
const transform: Transform<JS> = (root) => {
const rootNode = root.root();
const edits = [];
// --- Handle standard var declarations ---
// `var $DECL` structurally matches any variable_declaration whose
// first token is the `var` keyword — no text filtering needed.
const varDeclarations = rootNode.findAll({
rule: { pattern: "var $DECL" },
});
for (const decl of varDeclarations) {
if (isDeclaredTwice(decl, rootNode) || isHoisted(decl) || isInLoopWithClosure(decl)) {
continue;
}
const newKind = isMutated(decl) || isForLoopInit(decl) ? "let" : "const";
// Replace only the `var` keyword token (child 0).
// This is a targeted AST edit — we touch only the keyword,
// leaving the rest of the declaration untouched.
const varKeyword = decl.child(0);
if (varKeyword) {
edits.push(varKeyword.replace(newKind));
}
}
// --- Handle for...of / for...in with var ---
// Tree-sitter does not wrap `for (var x of items)` in a variable_declaration.
// The `var` keyword is a bare child token at position 2 of the for_in_statement.
// We match structurally with patterns and replace only that keyword token.
const forOfVars = rootNode.findAll({
rule: { pattern: "for (var $X of $Y) $BODY" },
});
for (const forOf of forOfVars) {
const varKeyword = forOf.child(2);
if (varKeyword?.kind() === "var") {
edits.push(varKeyword.replace("const"));
}
}
const forInVars = rootNode.findAll({
rule: { pattern: "for (var $X in $Y) $BODY" },
});
for (const forIn of forInVars) {
const varKeyword = forIn.child(2);
if (varKeyword?.kind() === "var") {
edits.push(varKeyword.replace("const"));
}
}
if (edits.length === 0) return null;
return rootNode.commitEdits(edits);
};
export default transform;
Testing the Codemod
Set up your test fixtures under the tests/ directory:
tests/
no-vars/
input.js
expected.js
hoisted-var/
input.js
expected.js
closure-in-loop/
input.js
expected.js
Then run the tests:
npx codemod jssg test -l javascript ./scripts/codemod.ts
Use the --verbose flag for detailed output when debugging:
npx codemod jssg test -l javascript ./scripts/codemod.ts -v
And if you've intentionally changed behavior, update the snapshots:
npx codemod jssg test -l javascript ./scripts/codemod.ts -u
Publishing to the Registry
Once your tests pass, you can publish your codemod to the Codemod Registry so others can use it:
npx codemod publish
Anyone can then run your codemod with:
npx codemod @your-scope/no-vars
See the publishing guide for setting up CI/CD with trusted publishers.
Wrapping Up
After applying this transform, we successfully convert var declarations to const or let wherever it's safe—handling edge cases like hoisting, duplicate declarations, and closure references in loops.
Before:
var notMutatedVar = "definitely not mutated";
var mutatedVar = "yep, i'm mutated";
for (var i = 0; i < 5; i++) {
mutatedVar = "foo";
var anotherInsideLoopVar = "should i be changed?";
}
for (var x of text) {
text += x + " ";
}
After:
const notMutatedVar = "definitely not mutated";
let mutatedVar = "yep, i'm mutated";
for (let i = 0; i < 5; i++) {
mutatedVar = "foo";
const anotherInsideLoopVar = "should i be changed?";
}
for (const x of text) {
text += x + " ";
}
Takeaways
- Identify patterns methodically. Do a thorough code search and capture as many possible patterns as you can before writing a single line of transform code.
- Test before you transform. Create test fixtures using the captured patterns—include both cases that should and should not be transformed.
- Use JSSG and ast-grep patterns. JSSG's pattern matching makes it easy to express complex structural queries without manually walking the AST.
- Publish and share. Once your codemod is tested, publish it to the Codemod Registry so your team (or the community) can run it with a single command.
Next Steps
- JSSG Quickstart — Build your first JSSG codemod in minutes.
- JSSG API Reference — Full reference for node navigation, editing, and pattern matching.
- Testing Guide — Learn about snapshot testing, strictness levels, and CI integration.
- Codemod Studio — Generate and test codemods visually with AI assistance.