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;
}
}
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 return
ing 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.