Reputation: 2824
I am trying to write a generic getter function, given a key which should only return the type string|boolean|number. The default value should therefore be of the same type as the returned value
Here is what I have tried.
getSomething = async<T extends string | boolean | number>
(key: string, defaultValue: T): Promise<T> => {
const foo = bar.getValue("foo"); // return type is undefined|string|boolean|number
return foo === undefined ? defaultValue : foo;
}
I get this error, not sure what is wrong.
Type 'string | number | boolean | T' is not assignable to type 'T'.
'string | number | boolean | T' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'string | number | boolean'.
Type 'string' is not assignable to type 'T'.
'string' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'string | number | boolean'.ts(2322)
What could possibly be a subtype of 'string | number | boolean' is what puzzled me. Aren't they already a primitive type?
Upvotes: 0
Views: 563
Reputation: 327679
Without the definition of bar
this isn't a minimal reproducible example. Since all I know is that bar.getValue()
returns a value of type string | number | boolean | undefined
, I will make my own bar
like this:
const bar = { getValue: (x: string): string | number | boolean | undefined => 12345 };
Then I write this code, which has no compiler errors:
const str = Math.random().toString();
getSomething("baz", str).then(v => console.log(v.toUpperCase()));
If you inspect the type of v
with IntelliSense in your TypeScript IDE, it will be string
. That's because the call signature of getSomething()
says that it will return a promise of the same type as the second parameter. We passed in str
, a string
, so we get a Promise<string>
out, right?
Oops, nope. Run the code and you'll get a runtime error and a message like TypeError: v.toUpperCase is not a function
. Because at runtime, v
will be 12345
, the actual value returned from bar.getValue()
. And 12345
is a number
, which has no toUpperCase
method. Somehow we got into a situation where a number
was mistaken for a string
. Where was the mistake?
It's exactly where the compiler warned you:
return foo === undefined ? defaultValue : foo; // error!
// Type 'string | number | boolean | T' is not assignable to type 'T'.
TypeScript is telling you that you are supposed to be returning a value of type T
, but the compiler can only verify that you are returning a value of type string | number | boolean | T
. In the case above, T
was string
, so you can interpret the error as something like "you're claiming to return a string
but all I know is that you're returning a string | number | boolean
, which might be a string
but maybe it's a number
or a boolean
in which case your claim is incorrect and bad things might happen".
Hopefully you understand why this is a problem. T
can be string
or number
or boolean
, which are all narrower than the union type string | number | boolean
. You can assign T
to string | number | boolean
but not vice versa.
About this question: "What could possibly be a subtype of string | number | boolean
is what puzzled me. Aren't they already a primitive type?" Well, string
is a subtype of string | number | boolean
. A union A | B
is a supertype of each of its members A
and B
.
Furthermore, even if you just had string
or number
or boolean
, there are subtypes of these in TypeScript: there are string literal types like the type "foo"
, numeric literal types like the type 123
, and even boolean literal types true
and false
(mentioned here and probably other places). These literal types represent specific values. So the types "a"
and "b"
are subtypes of string
, and the types 1
and 2
are subtypes of number
, and the types true
and false
are subtypes of boolean
.
So in fact, when you call getSomething()
with the second parameter as a string, numeric, or boolean literal, that's what gets inferred for T
:
const p = getSomething("qux", "peanut butter and jelly");
// const p: Promise<"peanut butter and jelly">
So not only does p
represent a promise of a string (which is not true in general), it actually represents a promise for the specific string "peanut butter and jelly"
. Oops.
So how do we fix this code? Well, that depends strongly on your use case. At first glance I'd say maybe it shouldn't be generic at all, and just allow both input and output to be string | number | boolean
:
const getSomething2 = async (key: string, defaultValue: string | number | boolean):
Promise<string | number | boolean> => {
const foo = bar.getValue("foo");
return foo === undefined ? defaultValue : foo;
}
That compiles with no error, and then the earlier code that had a runtime error but no compiler error now gives you a nice compiler error:
getSomething2("baz", str).then(v => console.log(v.toUpperCase())); // error!
// ---------------------------------------------> ~~~~~~~~~~~
// Property 'toUpperCase' does not exist on type 'number'.
The value v
is now known to be string | number | boolean
, and you can't call a toUpperCase
method on that because it might be a number
or a boolean
.
It's possible you need getSomething()
to be generic, but in that case it would really matter what bar.getValue()
does, and might require either bar.getValue()
's signature be modified, or a judicious type assertion somewhere inside getSomething()
where you are assuming the responsibility for verifying something the compiler cannot, and dealing with the consequences if your assertion turns out to be untrue at runtime. Your answer with return foo === undefined ? defaultValue : foo as T;
is unlikely to be the right sort of assertion, especially in light of literal types. I won't speculate further on this approach though. Suffice it to say that you'll need to think carefully about what claims you make when you use type assertions to make compiler errors go away.
Okay, hope that helps! Good luck!
Upvotes: 2
Reputation: 2824
I had to cast foo as T
change
return foo === undefined ? defaultValue : foo;
to
return foo === undefined ? defaultValue : foo as T;
I am not sure why I will need this explicit casting as foo
is definitely T
since it is not undefined.
And T
is constrained to type string | boolean | number
Upvotes: 0