Literally You
Literally You

Reputation: 65

Typescript does not recognize condition

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

Answers (2)

jcalz
jcalz

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

Linda Paiste
Linda Paiste

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.

Typescript Playground Link

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

Related Questions