Fortunately, a clever solution is available
I encountered a similar issue some time back. The use of the built-in InstanceType<TClass>
seems to be effective in resolving the instance type, but it comes with the constraint of requiring a public constructor:
type InstanceType<T extends abstract new (...args: any) => any> = ....
However, the limitation of needing a public constructor for InstanceType<TClass>
is quite restrictive. It's suitable for obtaining runtime results, but inadequate for creating open generic types (TS Conditional Types tend to mix run-time and compile-time type results in a confusing manner!)
Thankfully, we can leverage how the compiler resolves types to find the best matching signature and overlook the accessibility modifier to allow private / protected constructors as follows:
type InstanceTypeSpy<TClass> = InstanceType<{ new(): never } & TClass>;
This method works because technically, new(): never
does meet the expectations of InstanceType<>
, even though the compiler chooses not to match against new(): never
when possible to avoid resulting in never
- thus providing us with the actual type (or defaulting to never if there truly isn't any constructor on TClass
).
Fun Fact
To clarify the distinction between runtime and compile-time types, consider the recently introduced Awaited<T>
- which gives us the runtime type when awaiting an instance of T
. Now, imagine developing a generic library that needs to handle async
/ T = Promise<U>
where the precise type T
is unknown at compile time due to the unknown value of U
.
This scenario represents an "open" generic type - once the value of U
is known, it transforms into a "closed" generic type.
In such instances, we require the compile-time type; for example, if T = Promise<U>
, then we need to access U
.
The usage of Awaited<T>
won't suffice since it may not align with U
. If you attempt to assign a value of type U
to something that expects Awaited<T>
, a compile error will arise stating they are not assignable.
It's important to realize that await
is recursive in nature. Consider a scenario where at runtime, U
has a type of Promise<V>
; this would result in our T
being of type Promise<Promise<V>>
. Upon awaiting an instance of type T
, we receive a result of type V
, significantly differing from a result of type Promise<V>
. Thus, for an unknown T
, the correctness of such library code cannot be guaranteed by the compiler.
For reference, below is an example of Awaited
made non-recursive:
/**
* Non-recursive version of Awaited<T>, offering the compile-time type necessary for generics.
*/
export type PromiseResolveType<T> =
T extends null | undefined ? T :
T extends object & { then(onfulfilled: infer F): any } ?
F extends ((value: infer V, ...args: any) => any) ? V :
never :
T;