Reputation: 65
I have the following code:
type HTTPGet = {
url: string
params?: Record<string, unknown>
result: unknown
}
export const httpGet = <D extends HTTPGet>(url: D['url'], params: D['params'] = undefined): Promise<D['result']> => {
let path = url
if (params !== undefined) {
path += '?' + toHTTPQueryString(params);
}
return fetch(path, {
method: 'GET',
credentials: 'include',
headers: {
'Accept': 'application/json'
}
})
}
After condition params !== undefined
I call toHTTPQueryString, which accepts only Record<string, unknown> type.
But I have the TypeScript error:
TS2345: Argument of type 'D["params"]' is not assignable to parameter of type 'Record<string, unknown>'. Type 'Record<string, unknown> | undefined' is not assignable to type 'Record<string, unknown>'. Type 'undefined' is not assignable to type 'Record<string, unknown>'.
Why I get this message? TypeScript should understand that I have condition params !== undefined
and remains only Record<string, unknown>
type, that is assignable to toHTTPQueryString
parameter type.
Upvotes: 3
Views: 959
Reputation: 327934
For the longest time, TypeScript's compiler simply would not try to use control flow analysis to narrow the type of a value whose type depends on an as-yet-unspecified generic type parameter. In your case, checking params !== undefined
does not narrow params
from D["params"]
to something else. That's frustrating for the reason you state: you just checked params
for undefined
, why doesn't the compiler understand? This is the subject of a longstanding issue in GitHub: microsoft/TypeScript#13995.
And before yesterday, I'd have said "that's just how it is, sorry" and offered a workaround like the following: you could widen the value of generic type D['params']
to its specific constraint type HTTPGet['params']
and check that instead:
const httpGet = <D extends HTTPGet>(url: D['url'],
_params: D['params'] = undefined): Promise<D['result']> => {
let path = url
const params: HTTPGet['params'] = _params; // widen to specific type here
if (params !== undefined) {
path += '?' + toHTTPQueryString(params); // okay now
}
// ...
BUT... microsoft/TypeScript#13395 was just fixed by the pull request microsoft/TypeScript#43183, "Improve narrowing of generic types in control flow analysis". In TypeScript 4.3 and above, you can expect that in certain circumstances (you can read how in the pull request notes), values of generic types like D["params"]
will automatically be widened to their specific constraints to allow control flow analysis to proceed.
Meaning: if you run your above code as-is in a version of TypeScript after that pull request was merged, it will just work.
Observe: Playground link to code
It works!
I can't articulate how much of a coincidence the timing of this question is. The issue, microsoft/TypeScript#13995, has been open for four years, and was fixed yesterday. Of course it will take a little while for TypeScript 4.3 to be released, so you might not be able to take advantage of the fix immediately. But at least you know it's coming soon!
Upvotes: 2
Reputation: 42188
This is an interesting one. It's obviously a "mistake" on the part of Typescript to properly refine the type.
The error boils down to how typescript evaluates the type guard. Consider these two user-defined type guards. They both do the same check, but they define their signature differently.
const isDefined1 = <T extends any>(value: T | undefined): value is T => {
return value !== undefined;
}
const isDefined2 = <T extends any>(value: T): value is Exclude<T, undefined> => {
return value !== undefined;
}
if (isDefined1(params)) {
path += '?' + toHTTPQueryString(params); // error
}
if (isDefined2(params)) {
path += '?' + toHTTPQueryString(params); // no error
}
isDefined2
says "the type of value
now excludes undefined
". This one works.
isDefined1
most closely mimics what Typescript is doing with the automatic type guard params !== undefined
. It says "if the type of value
is a union with undefined
, drop the undefined
from the union." This one doesn't work and gives the same error that you had before. The type is still D['params']
which still includes undefined
.
It fails because D
is a generic so the actual type of D['params']
is unknown. We know that it extends Record<string, unknown> | undefined
but we don't know what it is exactly. So Typescript doesn't really evaluate it fully. It doesn't see it as a union and drop the undefined
because the actual type might not be a union.
If your function defined the params
argument without the generic as Record<string, unknown> | undefined
then both of these functions would work and the original params !== undefined
would work too.
But as it stands, you can use isDefined2
to guard the value.
As a sidenote, I'm surprised that you don't get any error on assigning a default value of undefined
to the argument params: D['params']
since specific D
types might not allow undefined
.
Upvotes: 3