I prepared a simplified example of an Angular Service, that comes with multiple Generic Types that allows overrides via the methods. The problem I face here is =
vs extends
and typing within the arguments. For some reason the =
works perfect for the return types but the argument types need the extends
.
class Service<
RequestBody,
ResponseBody,
CreateRequestBody = RequestBody,
CreateResponseBody = ResponseBody,
>
{
public createOne<
$CreateRequestBody extends CreateRequestBody,
$CreateResponseBody = CreateResponseBody
>(body : $CreateRequestBody) : $CreateResponseBody
{
return null;
}
public createTwo<
$CreateRequestBody = CreateRequestBody,
$CreateResponseBody = CreateResponseBody
>(body : $CreateRequestBody) : $CreateResponseBody
{
return null;
}
}
Here are the examples to reflect the problem, I want to use the createTwo()
method once I figured out the issue. At this point it does not validate the type for body.
const service: Service<{ foo: string }, void> = new Service<{ foo: string }, void>();
// working
service.createOne({
foo: 'test'
});
// not fine as the types are not compatible
service.createOne<void, void>({
bar: 'test'
});
// not fine as everything can be set for body - typing not working for some reason
service.createTwo({
bar: 'test'
});
// fine as the local override works
service.createTwo<{ bar: 'test' }, void>({
bar: 'test'
});
Here is a TypeScript Playground to see what the compiler does.
CodePudding user response:
First, let's remove the second two type parameters on Service
, since they don't seem to serve a purpose other than to be a "copy" of the existing request/response body types:
class Service<Rq, Rs> { /* ... */ }
Note that I'm using Rq
and Rs
to represent the original request and response body types, respectively, as I tend to follow the (admittedly had to read sometimes) convention where generic type parameters have short one-or-two character names so they can be distinguished from specific types.
So for the create()
method, what you generally want is for the body
type to be Rq
and for the return type to be Rs
. But, you'd also like to be able to override these types by explicitly specifying types when you call it, like create<NewRequestType, NewResponseType>(body)
, which would have body
be of type NewRequestType
and would return a NewResponseType
.
Disclaimer: At this point I want to note that it's hard to imagine any implementation of this function which could possibly be type safe, since the implementation at runtime will know nothing about what types were passed in for the compiler. For example, create<string, number>("hey")
should return a number
and create<string, boolean>("hey")
should return a boolean
, but both calls compile to the JavaScript create("hey")
, so there's no way even in principle for this to be accurate in all cases. It seems like you just want to let callers use these type parameters as if they were using type assertions, like create("hey") as number
or create("hey") as boolean
, which is at least an explicit and well-known way to acknowledge that you're giving up on type safety. But in the interest of progress, I'll consider type safety of the implementation out of scope and I won't belabor the point further.
So, you want generic call signature like <RqO, RsO>(body: RqO) => RsO
where RqO
and RsO
are the desired override types, but if you don't specify them they fall back to Rq
and Rs
respectively. But the big roadblock you'll have have is that, if you don't specify type parameters, the compiler will try to infer them. Whatever you pass in for body
will cause the compiler to infer that type for RqO
, since body
is an inference site for RqO
. Also, if you happen to call create()
in a place where the return type has an expected contextual type, the compiler will infer that type for RsO
, since the contextual return type is an inference site for RsO
. And that's not what you want; you want to disable type inference for RqO
and RsO
. It would be nice if you could say that body
should not be used to infer RqO
and the contextual return type should not be used to infer RsO
.
There's no offical way to "turn off" an inference site, but there's a feature request at microsoft/TypeScript#14829 which asks for some way to implement a type function like NoInfer<T>
which evaluates to T
eventually but cannot be used to infer T
. And luckily, there are some suggestions that work for at least some use cases. The one I usually use is from this comment:
type NoInfer<T> = [T][T extends any ? 0 : never];
In case it matters: here we use the fact that distributive conditional types that depend on unspecified generics are deferred, so T extends any ? 0 : never
is not evaluated until after T
is fixed. Whatever T
becomes, T extends any ? 0 : never
will eventually evaluate as 0
, and [T][0]
is just T
(because you are indexing into the first element of a single-element tuple type).
And that means we can write create()
like this:
public create<RqO = Rq, RsO = Rs>(
body: NoInfer<RqO>
): NoInfer<RsO> {
return null!;
}
We're using generic parameter defaults of Rq
and Rs
for RqO
and RsO
, so when the type parameters are not specified and when inference fails (which it definitely will), they will fall back to the defaults.
So, let's try it out:
const service = new Service<{ foo: string }, string>();
First, we will not specify the request and response types, and hopefully the compiler will fall back to the Rq
and Rs
types from service
:
const ret = service.create({
foo: 'test'
}).toUpperCase(); // okay
service.create({
bar: 'test' }); // error, {bar:string} is not {foo: string}
const ret2: number = service.create({
foo: 'test'
}); // error, number is not string
Looks good. Now we will specify the types, and hopefully the compiler will use them and not the ones from service
:
const ret3 = service.create<{ bar: string }, number>(
{ bar: "test" }).toFixed(2); // okay
const ret4 = service.create<{ bar: string }, number>(
{ foo: "test" }
); // error, {foo:string} is not {bar: string}
const ret5: string = service.create<{ bar: string }, number>(
{ bar: "test" }
Also looks good. So this is a viable solution.
Note that there are other ways to get non-inferential type parameter usages. You could add additional type parameters instead:
public create<
RqO = Rq,
RsO = Rs,
RqI extends RqO = RqO,
RsI extends RsO = RsO
>(body: RqI): RsI {
return null!;
}
which works because the compiler will use body
to infer RqI
(think "implicit" override) and the return type to infer RsI
, but the constraints are RqI extends RqO
and RsI extends RsO
and not vice versa. So the inferences for RqI
and RsI
will not affect RqO
and RsO
. You can verify that this behaves similarly. I won't go into more detail, but it's an approach that sometimes works when NoInfer<T>
does not.