DEV Community

Cover image for Rewrite JavaScript Behavior at Runtime with AST Mutation, From the Same Thread
typescript-guy
typescript-guy

Posted on

Rewrite JavaScript Behavior at Runtime with AST Mutation, From the Same Thread

I've spent the last few months building an open-source package called @typescript-guy/fn-monitor.

This post walks through the package and its most surprising part: rewriting a function's behavior at runtime, from the same thread — no build step, no workers, no message serialization.

In JavaScript, functions are fixed units. You define one, call it, and the code inside runs exactly as written. You can wrap it, monkey-patch dependencies, or use proxies around objects, but actually changing what happens inside the function usually requires one of these:

  • rewriting the source code
  • using a build-time transformation
  • running code in a worker or sandbox
  • manually patching every internal call site

@typescript-guy/fn-monitor offers a different approach.

It lets you execute a function through a JavaScript interpreter, and gives you hooks into the function’s AST while it executes.

That means you can do things like:

  • intercept specific AST nodes
  • inspect evaluated values
  • mutate operators
  • change return results
  • observe execution flow
  • build runtime control layers

To try this out locally, you can install the package from npm:

npm install @typescript-guy/fn-monitor
Enter fullscreen mode Exit fullscreen mode

The core idea

The main API looks like this:

import { monitor } from "@typescript-guy/fn-monitor";

const monitoredFn = monitor({
  main: {
    ref: originalFn,
  },
});
Enter fullscreen mode Exit fullscreen mode

Its core export is monitor.

You give it a function, and it returns a new function with the same call signature, but that function is executed by a custom interpreter instead of directly by the JS engine.

That interpreter layer is what makes runtime AST inspection and mutation possible. You can attach hooks such as:

  • beforeEachCall
  • afterEachCall
  • inspector
  • onStep

The most interesting one for AST mutation is the inspector.


Example: mutating an assignment operator at runtime

Here’s the first showcase from the README.

It demonstrates three important ideas at once:

  1. intercepting AST nodes during execution
  2. mutating execution behavior without changing the original source
  3. capturing external variables into the interpreter context
import { monitor } from "@typescript-guy/fn-monitor";

const zero = 0;

const sumUp = (nums: number[]) => {
    let sum: number = zero;
    for (const num of nums) {
        sum += num;
    }
    return sum;
}

const monitoredSumUp = monitor({
    main: {
        ref: sumUp,
        captures: {
            // since 'zero' is used by sumUp and is outside its scope,
            // we capture it into the interpreter's context
            zero
        }
    },
    beforeEachCall: (nums) => {
        console.log('Entered the monitored sum up function with the nums: ', nums);
    },
    inspector: (visit) => {
        visit.is('AssignmentExpression', event => {
            event.node.operator = "-="; // silently change the operator
            console.log('assignment result', visit.execute());
        });

        visit.is('ReturnStatement', event => {
            const result = visit.execute();
            const finalSum = event.scope.variables.search('sum');

            console.log('final sum: ', finalSum, 'Is result:', finalSum === result.RES);
            result.RES = 'I CHANGED THE VALUE';
        });
    },
    afterEachCall: (result) => {
        console.log('result of the monitored function: ', result);
    }
});

const arrToSum = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

const result1 = sumUp(arrToSum);
console.log('Result 1', result1);

const result2 = monitoredSumUp(arrToSum); // the exact same call signature
console.log('Result 2', result2);
Enter fullscreen mode Exit fullscreen mode

Output

Result 1 55

Entered the monitored sum up function with the nums:  [
  1, 2, 3, 4,  5,
  6, 7, 8, 9, 10
]
assignment result -1
assignment result -3
assignment result -6
assignment result -10
assignment result -15
assignment result -21
assignment result -28
assignment result -36
assignment result -45
assignment result -55
final sum:  -55 Is result: true
result of the monitored function:  I CHANGED THE VALUE
Result 2 I CHANGED THE VALUE
Enter fullscreen mode Exit fullscreen mode

What just happened?

The original function is still this:

const sumUp = (nums: number[]) => {
    let sum: number = zero;
    for (const num of nums) {
        sum += num;
    }
    return sum;
}
Enter fullscreen mode Exit fullscreen mode

But when it runs through the monitored version, the interpreter gives us a chance to intercept the AST node for the assignment expression.

This line is the key:

event.node.operator = "-=";
Enter fullscreen mode Exit fullscreen mode

That mutates the AST node itself.

So instead of executing:

sum += num
Enter fullscreen mode Exit fullscreen mode

the interpreted execution effectively behaves like:

sum -= num
Enter fullscreen mode Exit fullscreen mode

That is why the sum becomes -55 instead of 55.

Then the example intercepts the return statement:

visit.is('ReturnStatement', event => {
    const result = visit.execute();
    const finalSum = event.scope.variables.search('sum');

    console.log('final sum: ', finalSum, 'Is result:', finalSum === result.RES);
    result.RES = 'I CHANGED THE VALUE';
});
Enter fullscreen mode Exit fullscreen mode

And changes the final returned value.

So the function started as a simple summing function, but the monitored version returns:

"I CHANGED THE VALUE"
Enter fullscreen mode Exit fullscreen mode

without modifying the original source code.


A closer look at querying the scope

One subtle but powerful line in the example is this:

const finalSum = event.scope.variables.search('sum');
Enter fullscreen mode Exit fullscreen mode

Here, event.scope gives us a snapshot of the interpreted function’s scope at the point where the ReturnStatement is being handled.

This snapshot is read-only and freshly allocated for the event, so it lets you inspect the function’s internal state without directly exposing or mutating the interpreter’s internals.

Then:

event.scope.variables.search('sum')
Enter fullscreen mode Exit fullscreen mode

searches the scope chain for a variable named sum.

In this case, it finds the sum variable declared inside the monitored function:

let sum: number = zero;
Enter fullscreen mode Exit fullscreen mode

By the time the return statement is reached, the loop has already finished executing, so sum holds the final interpreted value:

-55
Enter fullscreen mode Exit fullscreen mode

That is why when we log this:

console.log('final sum: ', finalSum, 'Is result:', finalSum === result.RES);
Enter fullscreen mode Exit fullscreen mode

we get:

final sum:  -55 Is result: true
Enter fullscreen mode Exit fullscreen mode

At that moment, before we mutate the return value, the value inside the function scope and the value produced by the return statement are still the same.

Then we change the return result:

result.RES = 'I CHANGED THE VALUE';
Enter fullscreen mode Exit fullscreen mode

So the function still completes normally from the caller’s perspective, but the value it returns has been replaced.

This is one of the nice things about the API:

You are not limited to inspecting AST nodes only.

You can also inspect the interpreted scope around the node you intercepted.


Important detail: visit.is is eager

One thing worth understanding early is that visit.is(...) is not a global persistent hook.

From the README:

visit.is(query, callback) evaluates the query against the current node. If it matches, it allocates a scope, wraps it with the node in an event object, and fires the callback.

This does not register a persistent hook for future nodes. It is an eager, single-use check against the node currently being evaluated.

That design choice is intentional.

It keeps the interpreter faster and more memory-efficient.

So the mental model is:

As the interpreter walks the function, the inspector gets opportunities to inspect the current node. visit.is checks whether that current node matches what you care about.


Captures: bringing outside values into the interpreter context

In the example above, sumUp uses zero, which lives outside the function.

Because the function is executed by the interpreter, outside values need to be explicitly provided.

That’s what captures does:

main: {
    ref: sumUp,
    captures: {
        zero
    }
}
Enter fullscreen mode Exit fullscreen mode

This maps the name zero to the actual value from the surrounding JavaScript environment.

This is one of the practical parts of the API:

  • if your function depends on external primitives, objects, or functions
  • and those dependencies are not embedded into the interpreter context
  • you pass them in through captures

What this is good for

This approach is useful when you want:

  • runtime AST interception
  • programmatic execution control
  • experimentation layers
  • custom instrumentation
  • runtime behavior rewriting
  • execution introspection tools

What this is not

It is not a secure sandbox by default.

The README is explicit about that:

This package is not designed to act as a strict, secure sandbox out-of-the-box.

You can build stricter execution boundaries using hooks, but isolation is not the default guarantee.


A very important caveat: AST mutations persist

This is one of the most important notes in the README:

Because the code is parsed into an AST only once, any mutations made to an AST node within the inspector hook will persist and affect all subsequent calls to that function.

That means if you mutate an operator like this:

event.node.operator = "-=";
Enter fullscreen mode Exit fullscreen mode

you are mutating a reused AST node.

That can be powerful if you want a persistent runtime transform.

But it can also surprise you if you expected the mutation to apply only to one call.

So if you build something with AST mutation, be intentional about whether the mutation should be:

  • one-time
  • per-call
  • permanent for that monitored function instance

Other limitations worth knowing

To keep the article honest, here are the main constraints from the README:

  • the interpreter supports JavaScript syntax up to ES2024
  • monitor() has overhead, so call it once outside hot loops
  • you cannot use dynamic imports inside monitored functions
  • native generator functions (function*) cannot be monitored because they run outside the interpreter context
  • you cannot double-wrap a function via ref
  • errors inside monitored functions will not map directly to original source locations in your editor

These are reasonable tradeoffs for the capability, but they matter.


A good way to think about this package

@typescript-guy/fn-monitor is less like a logger and more like a runtime execution layer.

Instead of asking:

"What did this function return?"

you can ask:

"What AST nodes executed?"
"Can I intercept them?"
"Can I inspect their results?"
"Can I inspect the scope around them?"
"Can I change their behavior while they run?"


When runtime AST mutation makes sense

This kind of tool is especially interesting if you are building:

  • experimentation tools
  • runtime transforms
  • execution visualizers
  • teaching tools
  • instrumentation layers
  • advanced testing utilities
  • controlled evaluation pipelines
  • custom function wrappers with deeper introspection

It is probably overkill if all you need is:

  • basic logging
  • simple function wrapping
  • ordinary breakpoints

But if your goal is to control JavaScript execution programmatically at the AST level, this is a very interesting primitive.


Final thoughts

JavaScript usually gives you very little control over what happens inside a function once it starts executing.

@typescript-guy/fn-monitor changes that by running functions through an interpreter on the same JS thread and exposing hooks into the AST itself.

That lets you do things that normally feel out of reach in ordinary runtime JavaScript.

It is not a secure sandbox, and it is not free in terms of performance. But as a tool for runtime introspection and programmatic control, it opens up a genuinely interesting space.

If you have questions or ideas, drop a comment — I read all of them. The project is open source on GitHub and published on npm as @typescript-guy/fn-monitor, with runnable examples in the repo.

Top comments (0)