Background
I have an interface that declares a single method whose return type depends on which type parameters are supplied to the interface.
interface Command<T, U = void> {
execute(input: T): [U] extends [void] ? T | Observable<T>: T | U | Observable<T|U>;
}
I then have a utility type which should return the type parameters T
and U
for the supplied type.
type GetCommandParamTypes<T extends Command<unknown, unknown>> =
T extends Command<infer IO, infer A> ?
[IO, A] :
never;
Problem
Although GetCommandParamTypes
works as expected when supplied with the Command
interface, i'm struggling to debug why i'm not getting the result i expected when supplying it with a class that implements that interface.
type type1 = GetCommandParamTypes<Command<string>>;
// [string, void]
type type2 = GetCommandParamTypes<Command<string, string>>;
// [string, string]
type type3 = GetCommandParamTypes<Command<string | number, string>>;
// [string | number, string]
type type4 = GetCommandParamTypes<Command<string | number, Function>>;
// [string | number, Function]
type type5 = GetCommandParamTypes<Command<string, number | Function>>;
// [string, number | Function]
type type6 = GetCommandParamTypes<TypeACommand>; // expected [string, void]
// [string, string]
type type7 = GetCommandParamTypes<TypeBCommand>; // expected [string, number | Function]
// [string | number | Function, string | number | Function]
type type8 = GetCommandParamTypes<TypeCCommand>; // expected [string, void]
// [string, string | Observable<string>]
type type9 = GetCommandParamTypes<MixedTypeCommand>; // expected [string | number, boolean | Function]
// [string | number | boolean | Function, string | number | boolean | Function]
type type10 = GetCommandParamTypes<MixedTypeObservableCommand>; // expected [string | number, boolean | Function]
// [string | number | boolean | Function, string | number | boolean | Function | Observable<string | number | boolean | Function>]
Question
Should it be possible to infer type parameters from a Class that implements an interface or is there something wrong with my implementation?
Could someone please explain what happens when a Class is inspected to infer type parameters or point me in the direction of any resources that might help explain it?
Update 1
If i remove the conditional type from the command return type (which is not the desired behaviour as the second type parameter is optional and i don't want void
in the return type if it's not supplied) it does seem to get closer to what i was expecting. Or at least something a bit more workable.
interface Command<T, U = void> {
execute(input: T): T | U | Observable<T | U>;
}
At first glance it appears that the execute
method input
type is being returned for infer IO
and the return type is being returned for infer A
.
But for TypeACommand
that has the following signature:
execute(input: string): string | Observable<string>
[string, string]
is returned, not [string, string | Observable<string>]
Where as TypeCCommand
which has the following signature:
execute(input: string): Observable<string>
[string, string | Observable<string>]
is actually returned instead of [string, Observable<string>]
And MixedTypeObservableCommand
which also only has an Observable
as the return type also returns all the type parameters of the Observable<...>
and the Observable
itself.
So:
execute(input: string | number): Observable<string | number | Function | boolean>
becomes:
[string | number, string | number | boolean | Function | Observable<string | number | boolean | Function>]
Update 2
This is a link to a previous working example i created, before also adding Observable
to the return type of the Command
interface.
There at least appeared to be consistency in the types returned for infer IO
and infer A
for this version, where IO
was the execute
methods input type and A
was the execute
methods return type.
I was then able to use Exclude
to calculate the corresponding types in relation to the Command
interface
CodePudding user response:
The reason why this happens if because when checking is T
in GetCommandParamTypes
is an instance of Command
it doesn't actually check if the class you provided implements the interface. Typescript has structural, not nominal typings, so what it does is compare the signatures of Command<..., ...>
and of T
. Type TypeACommand
has a signature
type TypeOfACommandObject = {
execute(input: string): string | Observable<string>
}
There is nothing suggesting it implements Command
, it is just an object that has a method execute
with particular arguments and return types. If you now pass this object to GetCommandParamTypes
you will find the exact same problem: you would probably expect the return type to be [string, void]
, but it's actually [string, string]
. And there is no contradiction, because Command<string, string>
has signature
{
execute(input: string): string | string | Observable<string | string>
}
Which is indistinguishable from the signature of Command<string, void>
. So you cannot type it that easily.
The reason it will work correctly when you try GetCommandParamType<Command<string, void>>
, even though the signature of Command<string, void>
is as described above and you would probably expect it to be inferred incorrectly as well, is because of typescript's compiler implementation. I assume that when checking if type T
is an instance of Command
it first checks if T
is Command
. In this case it is literally and instance of Command
, so it just skips comparing the structure and yields the correct type arguments. TypeOfACommandObject
and TypeACommand
however are not direct instances of Command
, even though the latter implements it, so this shortcut fails. This is a speculation though, I'm not familiar with typescript implementation.
Speaking about fixing it, I can't really think of a good way without restructuring the return type of execute
so that T
and U
are not merged. I could think of branded types like adding a special unique signature to return type of command using unique symbol
type, but it will be very hard to use both when implementing Command
and when using it outside. You should probably try to separate T
and U
somehow
UPD:
In the updated example it works because of the different definition of GetCommandTypeParams
type ReplaceNever<T, U> = [T] extends [never] ? U : T;
type GetCommandParamTypes<T extends Command<unknown, unknown>> =
T extends Command<infer IO, infer A> ?
[IO, ReplaceNever<Exclude<A, IO>, void>] :
never;
If you stick this same definition in the first example, you will find that it also almost works as expected, the only problem is the last class with Observables
. So you'll need a small adjustment
type GetCommandParamTypes<T extends Command<unknown, unknown>> =
T extends Command<infer IO, infer A> ?
[IO, ReplaceNever<A extends Observable<infer U> ? Exclude<U, IO> : Exclude<A, IO>, void>] :
never;
This solution has a couple of weirdnesses like you won't be quite able to extract types from classes that implement Command<string | number, string | Function>
for example, but if you can live with it then it's fine