Handling AST Structure Differences and Visitor Patterns When Porting Babel Plugins to Oxc Transformer
Anyone who has written a Babel plugin at least once will recognize that the moment that experience crosses into the Rust world, the familiar API shapes become largely unfamiliar. Moving from a world where a single visitor object holds nodes, scopes, and manipulation methods all together, to a world where node references, lifetime parameters, and arena allocators each sit in separate places — the first impression of "it's just AST traversal" rarely lasts long. This article covers three structural differences you must understand when porting a Babel plugin to Oxc Transformer: how the visitor pattern is expressed, the intentional granularization of AST nodes, and the arena memory model.
Oxc is a JavaScript/TypeScript toolchain written in Rust, providing a parser, linter, transformer, resolver, and more as a single crate bundle. The fact that Rolldown uses Oxc as its internal transform engine is stated on the Oxc project's official page, and that alone makes it quite plausible that teams running custom Babel plugins will eventually need to evaluate an Oxc-based alternative. However, for exactly when and in which tool this will land, checking each project's official release notes is the most accurate approach.
Why Porting Becomes a Consideration
Babel is an ecosystem made up of over 170 npm packages. Its plugin API is rich and its community is mature. Oxc's appeal doesn't boil down to a single performance number — it comes from the architectural choice to design AST consumption so that multiple transforms share a single pass.
Babel traverses the AST independently for each plugin. Oxc has multiple transforms register their enter/exit handlers in the same traversal pass, and those handlers execute in registration order for a single node. Cache locality improves, but a new concern emerges: how multiple transforms each handle the same node and interact with one another. We'll return to this later.
Performance numbers and benchmarks are best verified in reproducible form at the oxc-project/bench-transformer repository. Because improvement figures reported by individual projects are strongly tied to that project's workload, this article does not cite specific case numbers and instead focuses on architectural differences.
Prerequisites: Crate Setup and Transform Registration Flow
Before porting, let's clarify which crates to add to the project and where to wire up the Traverse implementation you create. The following is a conceptual example using Oxc's public crate names as of 2026; you should verify the actual versions against the latest state on docs.rs.
# Cargo.toml (conceptual example)
[dependencies]
oxc_allocator = "*"
oxc_ast = "*"
oxc_parser = "*"
oxc_span = "*"
oxc_traverse = "*"// Conceptual example — check docs.rs for actual function signatures
use oxc_allocator::Allocator;
use oxc_parser::Parser;
use oxc_span::SourceType;
use oxc_traverse::traverse_mut;
fn run(source: &str) {
let allocator = Allocator::default();
let source_type = SourceType::default().with_module(true);
let ret = Parser::new(&allocator, source, source_type).parse();
let mut program = ret.program;
let mut my_transform = RemoveConsole;
traverse_mut(&mut my_transform, &allocator, &mut program /*, symbols, scopes */);
}The overall flow is as follows.
The key point is that the Allocator determines the lifetime of the entire pipeline. Every 'a that appears from this point on refers to this Allocator's lifetime.
Visitor Pattern: From visitor Object to the Traverse Trait
Babel's visitor pattern uses node type names as keys and handler functions as values.
module.exports = () => ({
visitor: {
CallExpression(path) {
if (path.node.callee.name === 'console') {
path.remove();
}
},
Identifier: {
enter(path) { /* enter */ },
exit(path) { /* exit */ },
}
}
});A single path holds the current node, the parent, the scope, and manipulation methods like replaceWith, remove, and insertBefore.
In Oxc, the Traverse trait implementation takes on this role.
use oxc_traverse::{Traverse, TraverseCtx};
use oxc_ast::ast::CallExpression;
struct RemoveConsole;
impl<'a> Traverse<'a> for RemoveConsole {
fn enter_call_expression(
&mut self,
node: &mut CallExpression<'a>,
ctx: &mut TraverseCtx<'a>,
) {
// Handle on enter
}
fn exit_call_expression(
&mut self,
node: &mut CallExpression<'a>,
ctx: &mut TraverseCtx<'a>,
) {
// Handle on exit
}
}Method names follow the enter_* / exit_* convention, and unimplemented methods use the trait's default no-op implementation. You only need to override the node types you care about.
The functionality that Babel's path provided is split in two in Oxc. The node itself is passed directly as &mut node, while context features like scope and symbol lookup, insertion, and UID generation come through ctx: &mut TraverseCtx<'a>. UID generation, commonly used in Babel, moves to the following form in Oxc (the exact method name and return type may vary by version, so checking oxc_traverse docs.rs is the safer approach).
// Conceptual example
// Babel: path.scope.generateUidIdentifier('temp') -> returns an Identifier node
// Oxc: generate a name/symbol for a new binding via ctx
// return type is typically a BoundIdentifier-like type (name Atom + SymbolId)
// convert to BindingIdentifier or IdentifierReference and insert into the nodeBabel's generateUidIdentifier returns a single Identifier that can be plugged in anywhere, but Oxc — for the reason we'll see in the next section — uses different node types per position, so binding-side and reference-side nodes are created and inserted separately. This difference is a point you'll encounter repeatedly during porting.
AST Structure: From One Identifier to Three Types
This is where the most compile errors actually appear during porting. Babel (ESTree) represents all names with a single Identifier node, determining whether it's a declaration or a reference from parent context. Oxc splits this into three types by purpose.
| Position | Babel/ESTree | Oxc |
|---|---|---|
x in const x = ... |
Identifier |
BindingIdentifier |
x in console.log(x) |
Identifier |
IdentifierReference |
property in obj.property |
Identifier |
IdentifierName |
AssignmentExpression.left type |
Pattern |
AssignmentTarget |
The practical benefit of this design is clear. Mistakenly inserting a reference node at a declaration site, or a binding node at a reference site, is caught by the compiler as a type error. A class of bugs that would only be found at runtime in Babel is blocked at build time.
The practical approach when porting is to check, at each point in the Babel code that handles an Identifier, whether that identifier is a declaration, a reference, or a static name. That tells you which Oxc type to use.
Node creation in Oxc is handled by AstBuilder, the equivalent of @babel/types. AstBuilder holds an arena reference with lifetime 'a, so nodes it creates automatically carry lifetime 'a.
// Conceptual example — check docs.rs for actual field/method names
use oxc_traverse::TraverseCtx;
fn make_something<'a>(ctx: &mut TraverseCtx<'a>) {
// How TraverseCtx exposes AstBuilder may differ by version.
// It may be a field (ctx.ast) or an accessor (ctx.ast()), so check the docs.
// let builder = ctx.ast;
// Create nodes with methods like builder.expression_call(...)
}Memory Model: AST in a World Without GC
Babel runs on top of JavaScript's GC, so you rarely need to think about node lifetimes. Oxc is different.
oxc_allocator::Allocator is a bump-pointer arena. All AST nodes are allocated inside this arena and released all at once when the arena is dropped. Eliminating individual heap allocation overhead is one of the reasons Oxc is fast.
The 'a lifetime parameter refers to this arena's lifetime. AstBuilder holds an arena reference as 'a, nodes created on top of it carry lifetime 'a, and Traverse trait implementations also receive 'a. That's why the form impl<'a> Traverse<'a> for MyTransform appears repeatedly. The less familiar you are with Rust, the more this lifetime propagation becomes an entry barrier — that's simply true.
The most common temptation for a workaround is to force 'static or sidestep with clone, but both approaches end up mixing values outside the arena with nodes inside it, producing bigger lifetime errors later. It's ultimately faster to follow the compiler's demands, attaching 'a wherever it's required and following the error messages from the start.
Porting Example: Removing console.log Statements
A simple example to verify the flow.
Original Babel
module.exports = () => ({
visitor: {
ExpressionStatement(path) {
const { expression } = path.node;
if (
expression.type === 'CallExpression' &&
expression.callee.type === 'MemberExpression' &&
expression.callee.object.type === 'Identifier' &&
expression.callee.object.name === 'console'
) {
path.remove();
}
}
}
});Oxc Porting Approach
Two things differ from Babel here. First, the object of a member expression is an Expression enum, and the identifier inside it is an IdentifierReference. Second, to remove the ExpressionStatement itself, you must directly mutate the parent's body: Vec<Statement>. Since there is no single call in the traverse context that maps exactly to Babel's path.remove(), the approaches commonly used in practice are one of the following:
- In
enter_program/exit_program(or the exit of a block-level container), iterate overprogram.bodyand filter out matchingStatement::ExpressionStatemententries usingretain - Collect the positions of nodes to delete during traversal, then remove them all at once when the container closes
Processing at the container level avoids the situation of mutating the parent vector during traversal while trying to immediately remove a node in enter_expression_statement.
// Conceptual example — Statement/Expression enum variant names and
// AST field structure should be verified in the actual oxc_ast documentation
use oxc_ast::ast::{Program, Statement, Expression};
use oxc_traverse::{Traverse, TraverseCtx};
struct RemoveConsole;
impl<'a> Traverse<'a> for RemoveConsole {
fn exit_program(&mut self, program: &mut Program<'a>, _ctx: &mut TraverseCtx<'a>) {
program.body.retain(|stmt| !is_console_call_statement(stmt));
}
}
fn is_console_call_statement(stmt: &Statement) -> bool {
let Statement::ExpressionStatement(expr_stmt) = stmt else { return false };
let Expression::CallExpression(call) = &expr_stmt.expression else { return false };
// In the Oxc AST, call.callee is an Expression, and
// member expressions exist as their own variants inside the Expression enum.
// Variant names (StaticMemberExpression, ComputedMemberExpression, etc.)
// must be verified in the latest oxc_ast documentation.
match &call.callee {
// Conceptual match: static member access whose object is console
// Expression::StaticMemberExpression(m) => matches_console(&m.object),
// Expression::ComputedMemberExpression(m) => matches_console(&m.object),
_ => false,
}
}The key isn't a line of code — it's a shift in responsibility. Where Babel's path.remove() found the parent and removed the node automatically, in Oxc the decision to "remove this node" must be made explicitly by the code that owns the container (the parent node). Inserting statements follows the same principle: apply them in bulk at the container's exit_* point, not mid-traversal.
Tradeoff Summary
| Item | Babel | Oxc |
|---|---|---|
| Visitor registration | visitor object (runtime) |
Traverse trait (compile time) |
| Node manipulation API | Unified path object |
&mut node + TraverseCtx separated |
| Identifier representation | Single Identifier type |
BindingIdentifier / IdentifierReference / IdentifierName |
| Memory management | GC automatic | Arena manual, 'a lifetime propagation |
| Plugin traversal | Separate pass per plugin | Single pass, handlers called in registration order |
| Dynamic plugin injection | Possible (runtime) | Mostly static compile-time composition |
| Type safety | Runtime errors possible | Prevented at compile time |
A few of the pitfalls encountered repeatedly during porting:
-
Attempting to match Babel's
Identifierdirectly. The Oxc AST enum only has position-specific types, so trying to directly translate Babel code by matching a variant namedIdentifierwill produce a compile error. You must first determine whether the identifier is a declaration, a reference, or a static name — that tells you which ofBindingIdentifier/IdentifierReference/IdentifierNameapplies. -
Directly translating
path.replaceWith()/path.remove(). Neither function has a single corresponding call. You must restructure the logic to directly mutate the parent node via&mut, or handle it in bulk at the container level (e.g., inexit_program). -
Quick fixes for
'alifetimes. Avoiding them with'staticor working around them withcloneleads to bigger lifetime problems later. Honestly propagating the arena lifetime from the start is ultimately faster. -
Transform interactions in a single pass. When multiple Babel plugins are ported and placed in one pipeline, multiple registered
enter/exithandlers for the same node execute in order. Logic that was safely isolated in separate passes in Babel is now exposed to each other's results within a single pass, so any logic that depends on the order of insertions or replacements needs to be revisited.
Closing Thoughts
The difficulty of Oxc porting is mostly not about "what do you rename the API to" — it's about accepting that the two systems have different design philosophies. Babel chose runtime flexibility; Oxc chose compile-time correctness and arena-based performance. The splitting of Identifier into three types, the separation of path into &mut node and TraverseCtx, the 'a appearing everywhere — all of these are natural consequences of that choice.
A realistic porting strategy isn't far from what the Oxc project's contributor guide suggests. In the first iteration, port the Babel logic into Rust as faithfully as possible and aim to pass Babel's test cases. Rust-idiomatic refactoring and performance optimization come after that. It's far more realistic to build a working port first and then refine it, rather than getting stuck at the first step trying to write perfectly idiomatic Rust.
References
- Oxc Transformer Alpha Release Blog (2024-09-29)
- oxc/crates/oxc_transformer/README.md — Contributor Guide
- oxc_traverse official Rust documentation (docs.rs)
- oxc_ast official Rust documentation (docs.rs)
- oxc_allocator official Rust documentation (docs.rs)
- Oxc Project Official Site
- oxc-project/oxc — GitHub Repository
- oxc-project/bench-transformer — Benchmark Repository
- traverse: port scope.push API from Babel — GitHub Issue #5049