Reputation: 15
I've been fiddling around recently with generic types, and while writing one function I had to determine the return type using one of the parameters from the function.
To give a general idea, here's more or less what I would like to be able to do:
const getString = () => 'string';
const getNumber = () => 9999;
const getNumberOrString =
<
T extends 'number' | 'string',
ReturnType extends T extends 'number' ? number : T extends 'string' ? string : void
>(which: T): ReturnType => {
switch (which) {
case 'number':
return getNumber();
case 'string':
return getString();
default:
return;
}
};
When running the code above on the Typescript Playground, I run into the following error messages:
Type 'number' is not assignable to type 'ReturnType'. 'number' is assignable to the constraint of type 'ReturnType', but 'ReturnType' could be instantiated with a different subtype of constraint 'string | number'.(2322)
Type 'string' is not assignable to type 'ReturnType'. 'string' is assignable to the constraint of type 'ReturnType', but 'ReturnType' could be instantiated with a different subtype of constraint 'string | number'.(2322)
Type 'undefined' is not assignable to type 'ReturnType'. 'ReturnType' could be instantiated with an arbitrary type which could be unrelated to 'undefined'.(2322)
The only way I can get this code running without any errors is by typecasting the return statements to any
, but is there any other way I can do this without having to resort to typecasting?
Upvotes: 0
Views: 224
Reputation: 330086
This is a known limitation in TypeScript; the evaluation of conditional types that depend on unspecified generic type parameters is deferred, and the compiler doesn't know how to verify that values are assignable to them.
Inside the implementation of getNumberOrString()
, the generic type parameter T
is unspecified (it only gets specified upon calling getNumberOrString()
), and so the return type T extends 'number' ? number : ...
is one of these deferred types. And so when you try to return any particular value like return getNumber()
, the compiler is unable to verify that number
is assignable to that type, and you get an error.
It would be nice if the compiler could use something like control flow analysis to understand that return getNumber()
can only happen when T extends 'number'
, but for now that is not the case. See microsoft/TypeScript#33912 for a feature request to implement support for this and a discussion about why it's not an easy problem to solve.
So for now, the only way to write functions with a generic conditional return type is to use type assertions (what you're calling "typecasting") or the equivalent (like a single-call-signature overload which allows you to loosen the implementation signature).
Here's how you might write your function:
type NumOrStrReturnType<T> =
T extends 'number' ? number :
T extends 'string' ? string :
never;
const getNumberOrString =
<T extends 'number' | 'string'>(which: T): NumOrStrReturnType<T> => {
switch (which) {
case 'number':
return getNumber() as NumOrStrReturnType<T>;
case 'string':
return getString() as NumOrStrReturnType<T>;
default:
throw new Error("I DIDN'T EXPECT THAT");
}
};
Note how getNumberOrString
has only one generic parameter. It looks like you were using the second generic parameter as a way to give a short name to the return type, but it can lead to weird behavior (e.g., it could be specified as a narrower type than intended (e.g., getNumberOrString<'number',42>('number')
) so I'm avoiding that. Instead I just gave a type alias for the return type and used it multiple times in type assertions.
Also note how instead of return
I have thrown an error in the impossible default
case. This, by the way, is another limitation of control flow inside generic function implementations. Even though it is potentially possible for which
to be narrowed to never
in that clause, the compiler doesn't even try, because it cannot narrow T
itself. See microsoft/TypeScript#13995 and microsoft/TypeScript#24085 for the feature requests to support that. You can work around it without the throw
, but that's going too far afield, I think.
The main point here is that any version of getNumberOrString()
using generic conditional types will require that you, the implementer, take responsibility for maintaining type safety, since the compiler is not up to the task as of TypeScript 4.1.
For the particular function you've written, the only way I know how to write it so that the compiler can actually maintain type safety is to represent the return type as a property lookup:
interface NumOrStr {
number: number;
string: string;
}
const getNumberOrString2 =
<K extends keyof NumOrStr>(which: K): NumOrStr[K] {
return ({
get number() { return getNumber() },
get string() { return getString() }
})[which];
}
The compiler sees getNumberOrString2()
as taking a param of type "number"
or "string"
and returning a value of type number
or string
as if it were looking up a property in an object of type NumOrStr
. And the implementation actually does that (via getters). This works as desired:
console.log(getNumberOrString2("number").toFixed(2)); // 9999.00
console.log(getNumberOrString2("string").toUpperCase()); // STRING
const sOrN = getNumberOrString2(Math.random() < 0.5 ? "string" : "number");
// const sOrN: string | number
Is it worth jumping through such hoops to get type safety from the compiler? Probably not. But it's at least possible to do it here, which is nice, I guess.
Upvotes: 1