typescriptproxy

Class method return type lost after proxy wrapping


I'm trying to create a wrapper that catches errors and returns a tuple of [result | undefined, error | undefined]. However, the TypeScript type inference isn’t working as expected. Here's my implementation:

const _catch = async <T>(
  cb: () => Promise<T> | T
): Promise<[T | undefined, string | undefined]> => {
  try {
    const result = await cb();
    return [result, undefined];
  } catch (error) {
    if (error instanceof Error) throw error;
    return [undefined, error as string];
  }
};

const wrapCatch = <T extends object>(instance: T, excludes: string[] = []) =>
  new Proxy(instance, {
    get(target, prop, receiver) {
      const original = Reflect.get(target, prop, receiver);
      if (typeof original !== 'function' || excludes.includes(prop as string))
        return original;
      return async (...args: any[]) => {
        return _catch(() => (original as (...args: any[]) => any).apply(target, args));
      };
    },
  });

function something(n : any){
   return "hello"
}

class Foo {
  constructor() {
    return wrapCatch(this);
  }

  async boo(params: string) {
    const result = something(params);
    // I want to use async function also
    // const result = await something(params)
    if (!result) throw "custom error";
    return result;
  }
}

When I call the method:

const result = new Foo().boo("any")

Expected type:

[Awaited<ReturnType<typeof something>> | undefined, string | undefined]

Actual type:

Awaited<ReturnType<typeof something>>

The proxy wrapper should modify the return type of the wrapped method to include the error tuple, but TypeScript isn’t inferring this correctly. How can I fix this type inference issue?

Now I wrote it by forcefully giving it a type like this.

type A<T> = Promise<[T | undefined, string | undefined]>;

function something(n : any){
   return "hello"
}

class Foo {
    constructor() {
        return wrapCatch(this);
    }

    async boo(params): A<ReturnType<somthing>> {
        const result = something(params);
        if (!result) throw 'custom error';

        return result;
    }
}

Solution

  • TypeScript currently assumes that a Proxy is the same type as its target. It has no ability to infer that the Proxy actually behaves as some other type. There's an existing issue for this at microsoft/TypeScript#20846, but unless that's ever implemented, you'll need to manually assert the type of the Proxy.

    In your case, it looks something like:

    type WrapCatch<T extends object, K extends keyof T> = { [P in keyof T]:
      P extends K ? T[P] :
      T[P] extends (...args: infer A) => infer R ?
      (...args: A) => Promise<[Awaited<R> | undefined, string | undefined]> :
      T[P] }
    
    const wrapCatch = <T extends object, K extends keyof T = never>(
      instance: T, excludes: K[] = []
    ) => new Proxy(instance, {
      get(target, prop, receiver) {
        const original = Reflect.get(target, prop, receiver);
        if (typeof original !== 'function' || (excludes as readonly PropertyKey[]).includes(prop))
          return original;
        return async (...args: any[]) => {
          return _catch(() => (original as (...args: any[]) => any).apply(target, args));
        };
      },
    }) as WrapCatch<T, K>;
    

    where I've defined WrapCatch<T, K> as what you intend to see when you wrapCatch(instance, excludes) where instance is of generic type T, and excludes is of generic type K constrained to be keys of T and which defaults to never so that leaving out excludes is the same as specifying [] at the type level.

    It's a mapped type where each property is a conditional type that wraps any functions whose keys are not in K into a new function that takes the same arguments, but which returns a Promise to a tuple of the right shape.


    TypeScript also assumes that the instance type of a class declaration is the same as the this. It has no ability to infer something different if you return something in the constructor. There's an existing issue for this too at microsoft/TypeScript#38519, but unless that's ever implemented, you'll need to work around it. I'd recommend not returning from your constructor, but instead having a function that wraps a class constructor. Like this:

    class _Foo {
      constructor() { }
      async boo(params: string) {
        const result = await something(params);
        if (!result) throw "custom error";
        return result;
      }
    }
    
    function Foo() {
      return wrapCatch(new _Foo());
    }
    
    const result = Foo().boo("any")
    //    ^? const result: Promise<[string | undefined, string | undefined]>
    result.then(x => console.log(x)) // ["ANY", undefined]
    

    Here _Foo is your underlying class, and you're not going to use it directly. And now Foo() is a regular function, whose body calls wrapCatch(new _Foo()), and thus returns a WrapCatch<_Foo, never>. You could try to make Foo a constructor instead of a function, maybe by some class wrapper factory mixin thing:

    function wrapClass<A extends any[], T extends object, U extends object>(
      ctor: new (...a: A) => T,
      wrapFn: (t: T) => U): new (...a: A) => U {
      return class extends (ctor as any) {
        constructor(...args: A) {
          super(...args);
          return wrapFn(this as any);
        }
      } as any
    }
    const Foo = wrapClass(_Foo, wrapCatch);
    // const Foo: new () => WrapCatch<_Foo, never>
    
    const result = new Foo().boo("yna");
    result.then(x => console.log(x)) // ["YNA", undefined]
    

    But I don't know if it's worth the trouble.

    Playground link to code