TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Optional chaining, proper infer in type guard

Open maciejsikora opened this issue 6 years ago • 7 comments

TypeScript Version: 3.7.0

Search Terms: optional chaining

Code

type X = {
  y?: {
    z?: string
  }
}
const x: X = {
  y: {
  }
}
// type guard
function isNotNull<A>(x: A): x is NonNullable<A> {
  return x!== null && x!== undefined;
}
// function which I want to call in the result of the expression
function title(str: string) {
  return str.length > 0 ? 'Dear ' + str : 'Dear nobody';
}

isNotNull(x?.y?.z) ? title(x.y.z) : null // type error - object is possibly undefined

The code fix is by adding additional checks or additional temporary variables:

(x?.y?.z && isNotNull(x.y.z)) ? title(x.y.z) : null
// or
const tmp = x?.y?.z
isNotNull(tmp) ? title(tmp) : null

Expected behavior: TypeScript is able to infer that optional chaining was used as an argument, what means that typeguard is checking the whole chain.

Actual behavior: TypeScript needs a code change in order to understand that optional chaining already checked other values from being null | undefined.

maciejsikora avatar Nov 07 '19 13:11 maciejsikora

This is a little tricky; TS sees the x is NonNullable<A> as an opaque treatment of the type of A. As humans we know that if x?.y?.z isn't undefined/null, then x.y can't be undefined/null, but TS doesn't have a way to apply that knowledge through the x is NonNullable<A> check because x is NonNullable<A> is just some generic type that happens to produce some value, not a special check that changes the types of the subexpressions that produced its type argument.

RyanCavanaugh avatar Nov 08 '19 21:11 RyanCavanaugh

I have a similar issue with type guards.

This compiles:

type Person = { name: string; }

const getName = (person?: Person): string => {
  return typeof person?.name === 'string' ? person?.name : '';
};

But this does not:

type Person = { name: string; }

const isString = (value: any): value is string => {
  return typeof value === 'string';
};

const getName = (person?: Person): string => {
  return isString(person?.name) ? person?.name : '';
};

// Type 'string | undefined' is not assignable to type 'string'.
//   Type 'undefined' is not assignable to type 'string'.

I would have expected these examples to be equivalent.

Is there another way to write the isString type guard function so that this works?

I’ve encountered the same problem. @ShuiRuTian will this be fixed by #38839 as well?

lo1tuma avatar Sep 27 '21 11:09 lo1tuma

@lo1tuma Sorry, but no.

As Ryan said, type guard is not a part of control flow analytics, so type narrowing is not working.

Here is a compare:

type X = {
    y?: {
        z?: string
    }
}
const x: X = {
    y: {
    }
}
// type guard
function isNotNull<A>(x: A): x is NonNullable<A> {
    return x !== null && x !== undefined;
}

if (x?.y?.z != null && x?.y?.z != undefined) {
    x.y.z // this works after 38839
}

if(isNotNull(x?.y?.z)){
    x.y.z // // However, this not
}



ShuiRuTian avatar Sep 28 '21 06:09 ShuiRuTian

TypeScript knows how to do this properly if you directly check that the chained variable is not null/undefined. It only fails if you use a type guard to check it.

This works:

if (animal?.breed?.size != null) {
  return animal.breed.size;
} else {
  return undefined;
}

This doesn't:

if (!isNil(animal?.breed?.size)) {
  return animal.breed.size;   // Error: Object is possibly 'undefined'. (2532)
} else {
  return undefined;
}

function isNil(value: unknown): value is undefined | null {
    return value == null;
}

Here is a playground link demonstrating this.

I don't know what TypeScript is doing internally, but the fact that one works and the other doesn't is what makes this feel like a bug to me rather than a type system limitation.

stabai avatar Jul 10 '22 22:07 stabai

This might be more tractable to fix now that NonNullable has a simpler definition

RyanCavanaugh avatar Aug 31 '22 18:08 RyanCavanaugh

What about ternary conditions? Would it be possible to also narrow those like here

function getBreedSizeWithTenary(animal: Animal): string|undefined {
    if (animal ? animal.breed ? animal.breed.size : undefined : undefined) {
        return animal.breed.size;
    } else {
        return undefined;
    }
}

here we also know that if the condition in if is true then animal is present, animal.breed is present so animal.breed.size should be valid and not required to use animal!.breed!.size or animal?.breed?.size right?

playground

@RyanCavanaugh any hope for that?

kbrilla avatar Oct 16 '22 16:10 kbrilla