Skip to main content

What is patching?

Patching (sometimes called monkey-patching) lets you wrap an existing function with your own logic: run code before it, after it, or in place of it. It’s how a plugin changes behavior it doesn’t own, from a Discord method deep in a module to a component’s render. The idea is simple. If a function lives on an object, you can replace that property with your own wrapper that calls the original. Doing this by hand is fiddly: you have to keep a reference to the original, restore it later, and cooperate with other plugins patching the same function. The patcher does all of that for you. It’s available as unbound.patcher and from @unbound-app/api.
import { patcher } from '@unbound-app/api';
The patcher does more than wrap a function. A few things it handles for you:

Full type inference

args, result, and this are typed from the target automatically. See Typing a patch.

Scoped cleanup

Patches grouped under a name revert together in one call.

Error isolation

Each callback runs in its own try/catch, so one bad patch won’t break the rest.

Garbage-collection safe

Targets are held weakly, so patching an object never keeps it alive on its own.

Creating a patcher

Call createPatcher with a name to get an instance scoped to your plugin. The name labels your patches so they can be reverted together. Every patch you apply through this instance is tracked, and one call reverts all of them.
import { patcher } from '@unbound-app/api';

const Patcher = patcher.createPatcher('my-plugin');
Give the patcher the same name as your plugin’s id. It keeps your patches grouped under one label and makes them easy to clean up in stop.

The three patch types

You hook a function at one of three points. Each takes the parent object, the method name on it, and a callback that receives a context object.
Runs before the original. The callback gets args, which you can mutate to change what the original receives. Useful for rewriting inputs or blocking a call.
Patcher.before(Module, 'method', ({ args, this: self }) => {
	args[0] = 'replaced';
});

The patch context

Every callback receives one context object. Which fields are meaningful depends on the patch type.
args
any[]
The arguments passed to the function. Mutate this in a before patch to change what the original receives.
result
any
The original’s return value. Available in after patches; return a value to replace it.
original
function
The unpatched function. Call it yourself in an instead patch to run the original behavior.
this
any
The this the function was called with. Bind it when you call original (original.apply(self, args)).
To pass data from a before patch to an after patch on the same call, stash it on this (or on an argument object) in before and read it back in after. The two callbacks share the same context for a given call.

The this context

ctx.this is whatever the function was called with, exactly as JavaScript resolved it at the call site. What that is depends on how the function was invoked.
Patching a method on a class prototype, this is the instance the method was called on. Read its props, state, or fields through ctx.this.
Patcher.after(Component.prototype, 'render', ({ this: self, result }) => {
	// self is the component instance
	if (self.props.hidden) return null;
	return result;
});
When you call original from an instead patch, pass ctx.this so the original keeps the right context: original.apply(self, args). Calling original(...args) drops this, which breaks any function that relies on it (most class and object methods do).

Returning values

Each patch type has two ways to change the call, and they’re interchangeable: pick whichever reads cleaner.
PatchMutate in placeOr return
beforeedit ctx.argsa new args array
afterset ctx.resultthe replacement result
instead(n/a)the function’s return value
A before patch can change the arguments either by editing ctx.args or by returning a fresh array. An after patch can set ctx.result or return a new value; returning nothing keeps the original result. An instead patch’s return value is the function’s return value, so to keep the original behavior you must call original and return what it gives you.
In an instead patch, forgetting to call original silently replaces the function with one that returns undefined. Only use instead when you actually intend to take over the call.

Typing a patch

The patcher infers types from the function you target. Point it at a typed module and args, result, and this are all typed for you, with no annotations.
// Given getUser(id: number): { name: string; age: number }
Patcher.after(Module, 'getUser', (ctx) => {
	ctx.args;   // [id: number]
	ctx.this;   // typeof Module
	ctx.result; // { name: string; age: number } | null
});
When you define a callback separately from the patch call, type it with PatchContext<Args, Result, Self>. All three parameters are optional and default to any, so pass only the ones you need.
import { patcher, type PatchContext } from '@unbound-app/api/patcher';

function logUser(ctx: PatchContext<[id: number], { name: string }>) {
	console.log(ctx.args[0]);   // number
	console.log(ctx.result);    // { name: string } | null
}

Patcher.after(Module, 'getUser', logUser);
ctx.result is Res | null. It’s null in before and instead callbacks (the original hasn’t run yet) and populated in after. Reach for it only in after patches.

One-time patches

Pass { once: true } as a fourth argument and the patch reverts itself automatically after it fires once. This is the right tool for reaching a value that only exists for a single render or call, such as patching a child component created fresh on each parent render (see Temporary patches from a parent).
Patcher.after(Module, 'method', ({ result }) => {
	return result;
}, { once: true });
Every patch call also returns an unpatch function, so you can revert a single patch yourself without waiting for it to fire.
const unpatch = Patcher.after(Module, 'method', callback);
// ...later:
unpatch();

Patching classes

The same three methods work on constructors. Patch the class on its parent module by name, and the context behaves the same: args are the constructor arguments, and in an after patch result is the new instance.
// Module.User is a class.

// Rewrite constructor arguments.
Patcher.before(Module, 'User', () => ['Jeff']);

// Take over construction.
Patcher.instead(Module, 'User', ({ args, original }) => {
	const instance = original(...args);
	instance.name = 'Intercepted';
	return instance;
});

// Adjust the instance after it's built.
Patcher.after(Module, 'User', ({ result }) => {
	result.name = result.name.toUpperCase();
	return result;
});

Execution order

When several patches target the same function, they run in a fixed order:

before

Every before patch runs, in the order they were registered.

instead (or the original)

The instead patches run. If there are none, the original function runs here.

after

Every after patch runs, in registration order, each seeing the result so far.
Each callback runs inside its own try/catch. If one patch throws, the error is logged and the remaining patches still run, so a single broken plugin won’t take down everyone else’s patches on the same function.

Cleaning up

Call unpatchAll in your plugin’s stop to revert every patch the instance applied. This restores each function to its original and lets the next plugin (or a re-enable of yours) patch cleanly.
import { patcher } from '@unbound-app/api';

const Patcher = patcher.createPatcher('my-plugin');

export default {
	start() {
		Patcher.after(Module, 'method', ({ result }) => transform(result));
	},
	stop() {
		Patcher.unpatchAll();
	},
};
A patch that outlives a disabled plugin keeps running with nothing behind it. unpatchAll in stop is not optional. If you patched a component’s render, also force a re-render so the original UI returns. See cleaning up.
You can also revert patches without holding the instance. patcher.unpatchAllByCaller(name) removes every patch registered under a caller name, and patcher.unpatchAll() (the module-level function, not the instance method) removes every patch globally.
import { patcher } from '@unbound-app/api';

// Revert just one plugin's patches by its caller name.
patcher.unpatchAllByCaller('my-plugin');
The caller name a patch is grouped under comes from the name you passed to createPatcher. Use your plugin id consistently and unpatchAllByCaller lets anything clean up your patches by that id.