Type safe monkey patching with TypeScript

monkey confused at a computer

How to wrap any function without using any types to keep your code as strict as possible.

Our goal is to have a universal solution so it should:

  • wrap function with zero or more arguments
  • preserve types of original function (infer arguments and return type)
  • should work under strict compiler options of TypeScript

This will allow us to:

  • create wrappers around other functions (e.g. log function results or dispatch analytics events)
  • accept other functions as arguments to call them inside
  • call other functions before or after target function
  • or other cases.

Solution

Look, no any and as SomeType lies to compiler πŸŽ‰

const wrap = <F extends (...args: never) => unknown>(f: F): F => f;

// no args
// βœ… const f1: () => number
const f1 = wrap(() => 55);
// with args
// βœ… const f2: (msg: string, shift: number) => number
const f2 = wrap((msg: string, shift: number) => msg.length + shift);

This solution works with strict compiler options of tsconfig.json

{
  "compilerOptions": {
    "strict": true,
  }
}

TypeScript version is 5.5.3 and you can play with this code on TS playground.

How it works

  • use generics function to infer types from arguments
  • constraint generic argument with extends
  • args: never is a trick here to allow path functions of different signatures with and without arguments
  • unknown as return type so we actually don't restrict return type

But, caveats...

Unfortunately, for more complex scenarios you may still need to mark your code with @ts-ignore or @ts-expect-error or @eslint-disable-next-line in cases like this:

const anotherWrap = <F extends (...args: never) => unknown>(f: F): F => {
    // ❌ Error Type '(...args: never) => unknown' is not assignable to type 'F'.
    //   '(...args: never) => unknown' is assignable to the constraint of type 'F', but 'F'
    //   could be instantiated with a different subtype
    //   of constraint '(...args: never) => unknown'.(2322)
    const internalWrap: F = (...args) => f(...args);
    return internalWrap;
};

Because TypeScript compiler cannot safely match types compatibility.

Even if you explicitly tell the compiler it is completely the same whole function type or the same arguments type:

// ❌ still cannot match proper type
const internalWrap: F = (...args) => f(...args);
const internalWrap2 = (...args: Parameters<F>) => f(...args);

It is really a pity that such a simple idea like monkey patching cannot work safely in TypeScript and requires lies to compiler like ignoring errors or loosing type safety via any type.