DEV Community

puffball1567
puffball1567

Posted on

Inside TypeScript 7: Arena Allocation and AST Memory Layout

When a compiler reads source code, it first turns the text into a tree called an AST (abstract syntax tree). Each part of the source—a function call, identifier, or expression—becomes a small object called a node in that tree. A large project can therefore create millions of nodes. TypeScript 7's Go compiler uses a small reusable allocator, called a typed arena, for selected node types such as CallExpression. This article follows the path from creating a call-expression node to returning the common *Node value, then separates what this allocator guarantees from what it does not.

The intended reader works with Go or TypeScript but does not need compiler-internals experience. The topic is a practical memory-layout question: what a typed arena changes about AST allocation, and what it does not.

I am not part of the TypeScript team. This is an independent technical reading of the publicly available source code and documentation, not an official explanation of the implementation or its design decisions.

What problem is this arena solving?

A compiler creates many nodes while it reads and analyzes source files. Here, an allocation means asking Go to reserve memory for one new object. If every node is created separately, Go's memory manager must handle many individual allocations, and objects of the same kind can be placed far apart in memory. TypeScript 7 gives some specific node structs—such as CallExpression—their own arenas. This appears intended to create repeated nodes in small batches instead.

Why care about individual heap allocations?

Go can allocate objects efficiently, but each allocation still requires work. Objects that must outlive the current function are typically placed in the heap, the area of memory that Go's garbage collector manages. Creating many separate heap objects increases allocator work and gives the garbage collector more objects to keep track of. An arena does not remove the pointers stored inside an AST node, so it does not eliminate all garbage-collection work. Instead, it can replace many small allocations with a smaller number of slice allocations, where one slice holds many values of the same node type.

The difference is easiest to see as a shape of allocations, not as a special kind of memory.

Direct allocation: one runtime-managed object per node

&CallExpression{} ──► [CallExpression]
&CallExpression{} ──► [CallExpression]
&CallExpression{} ──► [CallExpression]
                         ...
runtime sees many separately allocated objects

Typed arena: one backing slice holds several values of the same type

NodeFactory
  └─ callExpressionArena ──► [CallExpression | CallExpression | CallExpression | ...]
                                  ^ each New() returns a pointer to one element
runtime sees a much smaller number of backing-slice allocations
Enter fullscreen mode Exit fullscreen mode

This allocation pattern may also fit an AST's lifetime. A parsed tree is commonly kept as a whole while later compiler stages use it; a compiler usually does not discard one call-expression node at a time. Keeping same-type values in batches can therefore be a reasonable trade-off. In one batch, values sit next to one another in the slice's underlying array. When that batch fills, the arena creates a new array instead of moving the old one, so pointers already returned to callers remain valid.

The repository does not state a universal rationale for every arena choice, so these are the properties and typical benefits of the pattern—not a claim about an unstated TypeScript 7 performance result. It also does not mean that the whole AST becomes one contiguous block or that every tree walk becomes fast. The rest of this article shows the narrower mechanism for CallExpression.

This is a companion to Inside TypeScript 7: How Its AST Nodes Are Built and Traversed. The two articles can also be read independently.

Source and license note

This article discusses the publicly available microsoft/TypeScript repository. TypeScript 7's Go compiler is under tsc/ and is licensed under the Apache License 2.0. Code blocks marked as excerpts are short portions of the upstream source; diagrams and explanatory text are original. File-and-line citations in this article are pinned to source snapshot f29aeb9 so that later changes to main do not move the cited code.

Source excerpts are from Microsoft’s TypeScript project:
https://github.com/microsoft/TypeScript

Licensed under the Apache License, Version 2.0.
A copy of the License is available at:
https://github.com/microsoft/TypeScript/blob/main/LICENSE.txt

First, what an arena allocator usually means

An arena allocator is a way to manage the lifetime of a group of objects together. Instead of requesting memory separately for every object and releasing each one separately, a program creates an arena, asks it for objects as needed, and later discards or resets the entire arena at once. This is useful when a group of objects is expected to become unnecessary at roughly the same time—for example, temporary data used while processing one request or one compiler phase.

Typical arena lifetime

create arena
    │
    ├─ allocate object A
    ├─ allocate object B
    └─ allocate object C
    │
all three objects become unnecessary together
    │
reset or discard the arena
    └─ release the group as one unit
Enter fullscreen mode Exit fullscreen mode

The important idea is not that all memory is reserved up front. An arena can obtain more memory in batches as it fills. What makes it an arena is that allocation is grouped and its lifetime is commonly controlled at the arena level rather than one object at a time.

How TypeScript 7's Arena differs from a classic arena

TypeScript 7 uses the name Arena, but its implementation is deliberately smaller and works within ordinary Go memory management. It is an ordinary library data structure, not a special feature of the Go runtime. It stores a growable list (a slice) of one node type and returns pointers to entries in that list. It has New() and slice-allocation methods, but no public Free() or Reset() method that immediately releases all entries.

Classic arena                                 TypeScript 7 core.Arena[T]

program controls reset / discard              no explicit Free or Reset API
objects can be released as one region         Go's GC reclaims memory when nothing references it
often owns a general-purpose byte region      owns typed slices such as []CallExpression
Enter fullscreen mode Exit fullscreen mode

That distinction matters. A NodeFactory owns an Arena[CallExpression], but Go does not free its memory merely because one call-expression node is no longer needed. The backing arrays become collectible only after the factory, the AST nodes, and any other references keeping those arrays alive are no longer reachable. This design groups allocation, but it does not introduce manual memory management or bypass Go's garbage collector.

The relevant TypeScript 7 implementation is short. Source: tsc/internal/core/arena.go, lines 7–21.

// Annotated excerpt: internal/core/arena.go
type Arena[T any] struct {
    data []T // One backing slice for entries of the same concrete type.
}

func (a *Arena[T]) New() *T {
    if len(a.data) == cap(a.data) {
        nextSize := nextArenaSize(len(a.data)) // Decide the capacity of the next batch.
        a.data = slices.Grow[[]T](nil, nextSize) // Start a new backing slice; old pointers stay valid.
    }
    index := len(a.data) // The next unused element becomes the new entry.
    a.data = a.data[:index+1] // Extend the slice length so that entry belongs to the arena.
    return &a.data[index] // Return a pointer to that concrete value.
}
Enter fullscreen mode Exit fullscreen mode

Arena[CallExpression] therefore means “an arena whose list contains CallExpression values.” Its New() method returns a pointer, *CallExpression, to one newly reserved entry. The standard-library documentation for slices.Grow explains the capacity-growth operation used here.

TypeScript 7 keeps an arena per selected node type

NodeFactory is the object responsible for constructing AST nodes. Think of it as the place that both knows how to fill in a node's fields and owns the typed storage used while making that kind of node. For example, after the parser recognizes client?.request<User>(id), it can ask the factory to make one call-expression node from the pieces it already recognized.

parser has recognized: client?.request<User>(id)
        │
        │ passes the four meaningful pieces to NodeFactory
        ▼
NewCallExpression(expression, ?., <User>, (id))
        │
        ├─ reserve an empty CallExpression entry from its arena
        ├─ put those four pieces into that entry
        └─ return the entry's shared *Node portion
Enter fullscreen mode Exit fullscreen mode

NodeFactory has many fields. The two fields relevant to this article are its typed arena and its hooks field. hooks is simply a group of optional functions that a caller can provide while creating a factory; the next section shows exactly when OnCreate is called. Sources: tsc/internal/ast/ast.go, lines 61–65 for the hooks and tsc/internal/ast/ast_generated.go, lines 20–25 for the typed arena field.

// Annotated excerpts, shortened to the fields relevant here.
type NodeFactoryHooks struct {
    OnCreate func(node *Node)                 // Optional function called after creating a node.
    OnUpdate func(node *Node, original *Node) // Optional function called after updating a node.
    OnClone  func(node *Node, original *Node) // Optional function called after cloning a node.
}

type NodeFactory struct {
    hooks                NodeFactoryHooks          // The optional functions supplied when this factory was created.
    callExpressionArena core.Arena[CallExpression] // Storage used when constructing CallExpression values.
}
Enter fullscreen mode Exit fullscreen mode

The full CallExpression object stores information that only a call expression needs: what is being called, whether ?. is present, and its type and ordinary arguments. It also contains a shared Node portion used by every kind of AST node. The generated type and factory then look like this. Source: tsc/internal/ast/ast_generated.go, lines 4256–4274.

// Annotated excerpt: internal/ast/ast_generated.go
type CallExpression struct {
    LeftHandSideExpressionBase // Embeds common behavior for an expression that can appear on the left-hand side.
    DeclarationBase            // Embeds declaration-related shared state.
    CompositeBase              // Reaches the shared Node header through the embedded base types.
    Expression       *Expression        // The expression being called, such as client.request.
    QuestionDotToken *QuestionDotToken  // The ?. token, when this is an optional call.
    TypeArguments    *TypeList          // Type arguments such as <User>, when present.
    Arguments        *ElementList       // Ordinary call arguments such as (id).
}

func (f *NodeFactory) NewCallExpression(expression *Expression, questionDotToken *QuestionDotToken, typeArguments *TypeList, arguments *ElementList, flags NodeFlags) *Node {
    data := f.callExpressionArena.New() // Reserve a *CallExpression entry in this factory's arena.
    data.Expression = expression         // Store the callee.
    data.QuestionDotToken = questionDotToken // Store ?. or nil.
    data.TypeArguments = typeArguments   // Store <T> arguments or nil.
    data.Arguments = arguments           // Store the ordinary argument list.
    node := f.newNode(KindCallExpression, data) // Initialize and return the embedded common Node.
    node.Flags |= flags & NodeFlagsOptionalChain // Preserve the optional-chain bit from the caller.
    return node // Callers use the common *Node API rather than *CallExpression directly.
}
Enter fullscreen mode Exit fullscreen mode

For client?.request<User>(id), those fields represent the called expression, the optional-chain token, type arguments, and ordinary arguments. Read the code one line at a time:

  1. f.callExpressionArena.New() returns the address of one empty CallExpression already stored in the arena. The local variable named data receives that address.
  2. Lines such as data.Expression = expression write values into that one CallExpression.
  3. f.newNode(KindCallExpression, data) receives the address of that same CallExpression. It initializes the shared Node portion contained inside the object.
  4. The returned *Node is not a new, separate object. It is the address of the common Node portion inside the same CallExpression.

In short, the factory reserves one CallExpression in the arena, fills its call-specific and shared fields in order, then returns it in a form that later code can treat as a general AST node. The full generated implementation, lines 4256–4289 also shows its child-traversal method.

The allocation and initialization path is therefore as follows. There is one object throughout this flow—not one CallExpression object plus another Node object.

NewCallExpression(...)
        │
        ├─ callExpressionArena.New()
        │       └─ returns one *CallExpression inside a batch
        │
        ├─ write Expression / TypeArguments / Arguments into that value
        │
        └─ newNode(KindCallExpression, data)
                └─ initializes the shared Node portion inside that same object, then returns *Node
Enter fullscreen mode Exit fullscreen mode

What NodeFactory.newNode does

There are two functions named newNode. The call in NewCallExpression is the method on NodeFactory, written f.newNode(...). Its job is deliberately small: count that the factory made one more node, then pass the work and the factory's hooks to the package-level newNode(...) function.

// Excerpt: tsc/internal/ast/ast.go, lines 75–88.
// nodeData and Node are defined in the next section.
func (f *NodeFactory) newNode(kind Kind, data nodeData) *Node {
    f.nodeCount++                 // Record that this factory created one more AST node.
    return newNode(kind, data, f.hooks) // Delegate the shared-field initialization below.
}

// This package-level helper is the second function with the same name.
func newNode(kind Kind, data nodeData, hooks NodeFactoryHooks) *Node {
    n := data.AsNode() // Find the common Node portion inside the full object.
    n.Loc = core.UndefinedTextRange() // Give its source range an initial value.
    n.Kind = kind // Record the supplied kind; here, it is KindCallExpression.
    n.data = data // Keep a way back to the full CallExpression object.
    if hooks.OnCreate != nil {
        hooks.OnCreate(n) // Run an optional callback after initialization.
    }
    return n // Return the common Node portion inside that same object.
}
Enter fullscreen mode Exit fullscreen mode

When does OnCreate run?

OnCreate is not an event that fires automatically at some later time. It is an optional function value supplied when a factory is created. It runs synchronously during node creation only when both of these conditions are true:

  1. The code that created this NodeFactory provided a non-nil OnCreate callback.
  2. A factory method reaches the package-level newNode, which checks hooks.OnCreate != nil and calls it directly.

The printer is one concrete setup site:

// Shortened excerpts: tsc/internal/printer/factory.go, lines 18–24,
// and tsc/internal/printer/emitcontext.go, lines 71–73.
func NewNodeFactory(context *EmitContext) *NodeFactory {
    return &NodeFactory{
        NodeFactory: *ast.NewNodeFactory(ast.NodeFactoryHooks{
            OnCreate: context.onCreate, // Register this function on the AST factory.
            // OnUpdate and OnClone are also registered in the real code.
        }),
    }
}

func (c *EmitContext) onCreate(node *ast.Node) {
    node.Flags |= ast.NodeFlagsSynthesized // Mark a printer-created node as synthesized.
}
Enter fullscreen mode Exit fullscreen mode
create factory with OnCreate callback
        │
        ▼
factory.NewCallExpression(...)
        │
        ▼
newNode(...)
        │
        ├─ OnCreate is nil  ───► do nothing
        └─ OnCreate is set  ───► call it immediately with the new *Node
Enter fullscreen mode Exit fullscreen mode

For example, the printer creates its factory with context.onCreate as the callback. That callback marks each node created through this printer factory as synthesized. A factory created with ast.NodeFactoryHooks{} instead has no callback, so this step is skipped. Sources: tsc/internal/printer/factory.go, lines 18–24, tsc/internal/printer/emitcontext.go, lines 71–73, and tsc/internal/ast/ast.go, lines 75–83.

So the two names represent two levels of responsibility:

f.newNode(...)                  newNode(...)
NodeFactory method              package-level helper

increments this factory's       fills the common Node fields
node count                      and runs an optional creation hook
        │                                  │
        └────────────── calls ────────────┘
Enter fullscreen mode Exit fullscreen mode

The factory links the common node header back to the concrete object

Every AST node needs some information that is common to all nodes: its kind, flags, source range, and parent. TypeScript 7 puts this shared information in Node, which works as a small common header inside the full object. The private nodeData interface describes what a full node object must provide. The package-level newNode shown above uses its AsNode method to find that embedded common header. Sources: tsc/internal/ast/ast.go, lines 75–88, lines 180–187, and lines 1184–1205.

// Annotated excerpt, with nodeData shortened to the method used below.
type Node struct {
    Kind   Kind            // Identifies which concrete AST node this is.
    Flags  NodeFlags       // Holds shared syntactic and analysis flags.
    Loc    core.TextRange  // Records the node's source range.
    Parent *Node           // Points to the enclosing node when a parent has been assigned.
    data   nodeData        // Points back to the full concrete object, such as *CallExpression.
}

type nodeData interface {
    AsNode() *Node // Returns the common Node embedded in the concrete object.
    // The real interface also defines traversal, cloning, and analysis methods.
}
Enter fullscreen mode Exit fullscreen mode

CallExpression reaches Node through its embedded base types. In other words, the full call-expression object contains the shared Node portion inside it. newNode obtains that inner Node as n, then stores a reference to the full *CallExpression back in n.data.

*CallExpression allocated by callExpressionArena
├─ embedded base types
│  └─ shared Node  ← returned *Node
│     └─ data ────────────────┐
├─ Expression                 │
├─ TypeArguments              │
└─ Arguments                  │
                              │
data holds *CallExpression ───┘
Enter fullscreen mode Exit fullscreen mode

This is why later code can start from a generic *Node, inspect Kind, and recover its concrete representation when necessary. The cited lines above show the production definitions, including the full nodeData interface.

What this memory layout guarantees

Within one active arena slice, entries of the same type are adjacent array elements. If a factory creates several CallExpression values while the slice still has capacity, their CallExpression structs are stored together. That can improve locality when code repeatedly works on the same node type.

When the slice is full, this arena does not grow the old backing array in place. It creates a new backing slice with slices.Grow(nil, nextSize) and begins placing subsequent values there. Existing pointers still point to elements in the old backing array; the allocator never moves or overwrites those entries. As long as the AST retains those pointers, Go retains the old allocation too.

The result is better described as type-local batches than as “one contiguous AST.”

CallExpression arena
├─ batch A: CallExpression, CallExpression, CallExpression, ...
└─ batch B: CallExpression, CallExpression, ...

Identifier arena
└─ its own batches of Identifier values

Other node types
└─ their own allocation strategies and locations
Enter fullscreen mode Exit fullscreen mode

The pointers inside a call expression can lead to identifiers, argument lists, tokens, or other node types stored elsewhere. Tree order, source order, and physical memory order are therefore different concepts.

Why not put every node type in one arena?

The repository does not state one universal policy for which node types receive an arena. The generated factory includes arenas for many types, while some constructors allocate with a direct &Type{} expression. It would be speculation to treat the presence of an arena as proof that one node type is always more important than another.

What can be stated from the code is narrower: the design permits the factory to choose an allocation strategy per concrete type while preserving the same returned *Node API. Callers of NewCallExpression do not need to know whether the concrete object came from an arena or a direct allocation.

The trade-off: locality and allocation patterns, not automatic speed

An arena can reduce per-object allocation work and group same-type objects, but it adds a custom lifetime and growth model. It also does not make arbitrary AST traversals cache-friendly: a traversal frequently follows pointers between different types and different batches.

TypeScript 7's overall performance comes from more than this allocator. The TypeScript team attributes TypeScript 7's gains to native code, shared-memory parallelism, and other optimizations; an AST arena alone does not explain the result. The TypeScript 7 announcement provides the broader performance context.

Reference measurement: does the expected allocation effect appear?

The mechanism above suggests that replacing one allocation per node with type-local batches should greatly reduce the number of objects allocated and tracked by the runtime. I tested that expectation with a small Go benchmark that constructs one live 100,000-node binary tree. The direct version allocates each node with &node{}; the arena version matches TypeScript 7's slice-backed growth policy, doubling a batch up to 256 elements and then allocating additional 256-element batches. This is an explanatory microbenchmark, not a TypeScript compiler benchmark: it does not parse TypeScript, type-check, emit output, or measure the production node types.

On Go 1.23.5, Linux/amd64, and one CPU on an AMD Ryzen 5 5600H, five runs produced the following medians. The final column comes from a separate forced-GC measurement after constructing one retained tree; it is not a testing.B allocation result.

Construction strategy Median time for 100,000 nodes Bytes/op Allocations/op Retained heap objects after one live tree
Direct &node{} allocation 13.59 ms 7,202,816 100,001 99,994
Typed growing arena 9.50 ms 6,422,288 350 355

In this deliberately narrow setup, the expected effect appeared: batching reduced allocation operations by about 99.7% and construction time by about 30%. Retained bytes were also lower here, but that is not guaranteed. An arena keeps its backing batches alive, including unused capacity in the final batch. This result supports the expected benefit of reducing individually allocated and tracked objects; it does not prove that an arena is always faster or smaller for every AST workload.

The reusable lesson is to measure the allocation and traversal pattern first. A typed arena is useful when a program creates many short- or similarly-lived objects of known concrete types. It is not a default replacement for ordinary Go allocation.

Kinmokusei

Kinmokusei is a programming language with TypeScript-inspired syntax that compiles to readable Go. It is designed to work with the normal Go toolchain and package ecosystem.

Top comments (4)

Collapse
 
mikachu profile image
Mika Flowers

Really solid write-up 👏 great distinction between a traditional arena allocator and what TypeScript 7 is actually doing here with typed slices + Go’s GC. Learned something new!

The breakdown of CallExpression → newNode() also made the memory layout way easier to visualize. Compiler internals can get intimidating fast, but this made the implementation feel surprisingly approachable. Great work!

Collapse
 
puffball1567 profile image
puffball1567

I am very happy to hear you say that. I hope to continue writing articles that prove useful. I also plan to present this content as a series, so that by reading the articles as a whole, you can gain knowledge and information from a variety of perspectives.

Collapse
 
mihai_leanzero profile image
Mihai Perdum

Pulled the generated factory to check the "why not put every node type in one arena" question you left open. 48 arena fields against roughly 193 New* constructors, and most of that set does read like the high-frequency kinds you'd expect: Identifier, Token, StringLiteral, NumericLiteral, PropertyAccessExpression, CallExpression, BinaryExpression.

But jsdocDeprecatedTagArena and jsdocUnknownTagArena are in there too, and those are about as rare as a node kind gets in real source. So whatever decided this set, it isn't pure frequency, at least not consistently. Reads more like whoever touched a given constructor happened to reach for an arena than a deliberate frequency policy either way. Still a genuinely well-instrumented piece, the microbenchmark table especially.

Collapse
 
puffball1567 profile image
puffball1567

Thank you for reading to the end.

I conducted this verification with the hope that the article would prove useful to everyone.

I am also delighted to have gained so much insight and made new discoveries myself through the process of conducting this verification and writing the article.