Home > Mobile >  Can't assign to NonNullable<T> even though I checked that value is defined
Can't assign to NonNullable<T> even though I checked that value is defined

Time:04-12

Trying to reach peak TypeScript performance I'm currently getting into some of the niche areas of the language, and there's something I don't get.

With strict-null-checks and strict mode and that stuff enabled, I don't understand why this code gives me an error:

function filterNullable<T>(arr: T[]): NonNullable<T>[] {
    const ret: NonNullable<T>[] = [];
    arr.forEach(el => {
        if (el !== null && el !== undefined) {
            ret.push(el); // TS2345: Argument of type 'T' is not assignable to parameter of type 'NonNullable '.
        }
    })
    return ret;
}

Clearly, even if T is a type like string | number | undefined, by the time I'm trying to push the filtered value into the array, it's pretty damn clear that it can't be undefined. Is TypeScript not smart enough to follow me here (I don't think so, because similar things work just fine), am I missing a crucial detail, or is the NonNullable type simply not as powerful as one might expect?

(I'm aware that there are more concise methods of doing what I'm doing here, however I need code with a similar logic to do more complicated things, so the Array.prototype.filter style method is not quite applicable)

-------Edit: I kind of get the problem now, however, instead of using type assertions in the push method, I'd then rather rewrite the whole thing to something like

function filterNullable2<T>(arr: Array<T | undefined | null>): T[] {
    const ret: T[] = [];
    arr.forEach(el => {
        if (el !== null && el !== undefined) {
            ret.push(el);
        }
    })
    return ret;
}

which seems to do a better job. But it needs more writing.

What I'm really trying to do is find a better way to write a utility for NGRX, as this is really annoying (thank god there isn't a third nullish type)

import {MemoizedSelector, Store} from "@ngrx/store";
import {filterNullable} from "../shared/operators";
import {Observable} from "rxjs";

export function selectDefined<T, S> (store: Store<T>, selector: MemoizedSelector<T, NonNullable<S>>): Observable<S>;
export function selectDefined<T, S> (store: Store<T>, selector: MemoizedSelector<T, NonNullable<S> | null>): Observable<S>;
export function selectDefined<T, S> (store: Store<T>, selector: MemoizedSelector<T, NonNullable<S> | undefined>): Observable<S>;
export function selectDefined<T, S> (store: Store<T>, selector: MemoizedSelector<T, NonNullable<S> | undefined | null>): Observable<S> {
  return store.select(selector).pipe(filterNullable());
}

I'd hope to find a way to write this like

export function selectDefined<T, S> (store: Store<T>, selector: MemoizedSelector<T, S>): Observable<NonNullable<S>> {
  return store.select(selector).pipe(filterNullable());
// TS2322: Type 'Observable<S>' is not assignable to type 'Observable<NonNullable<S>>'.   Type 'S' is not assignable to type 'NonNullable<S>'.
}

which, unfortunately, does not work

CodePudding user response:

After the null and undefined checks the variable el remains of type T, as you could still assign null or undefined to the variable inside the if statement.

To solve this problem just tell the compiler that you are pushing an NonNullable<T>:

ret.push(el as NonNullable<T>)

To make your code a little bit more concise:

function filterNullable<T>(arr: T[]): NonNullable<T>[] {
    return arr.filter((s) : s is NonNullable<T> => !!s)
}

CodePudding user response:

This is essentially a design limitation of TypeScript. Inside the implementation of a generic function, it is difficult for the compiler to reason about types that depend on unspecified type parameters like T. See microsoft/TypeScript#48048 for an authoritative answer.


For a value of a specific type like string | undefined, the compiler can use control flow analyasis to filter the type of the value to either string or undefined. Indeed, your code works just fine if we replace the generic T with the specific string | undefined:

function filterNullable(arr: (string | undefined)[]): NonNullable<string | undefined>[] {
  const ret: NonNullable<string | undefined>[] = [];
  arr.forEach(el => {
    if (el !== null && el !== undefined) {
      ret.push(el); // okay
    }
  })
  return ret;
}

Unfortunately, for a value of a generic type like T, there is no specific filtering to be applied. If the compiler knew that T were constrained to a union of types, then the null check could possible narrow el from T to some specific subtype of the constraint, like this:

function filterNullable<T extends string | undefined>(arr: T[]): NonNullable<T>[] {
  const ret: NonNullable<T>[] = [];
  arr.forEach(el => {
    if (el !== null && el !== undefined) {
      el.toUpperCase() // this works
      ret.push(el); // this still doesn't
    }
  })
  return ret;
}

See how since T extends string | undefined, the compiler realizes that el must be assignable to string after the null check. That's great, and support for that was added in TypeScript 4.3 as contextual narrowing for generics. But that's not good enough for you, since you need el to be narrowed not to the specific type string but to the generic type NonNullable<T> (which is surely some subtype of string but might be narrower, if, say, T is "foo" | "bar" | undefined).


And here we're stuck. The compiler simply does not synthesize the type NonNullable<T> in response to a null check. The NonNullable<T> utility type is implemented as a conditional type, and conditional types are not synthesized by the compiler as a result of control flow analysis.

At one point there was a pull request at microsoft/TypeScript#22348 which would have enabled such narrowings. But it was never merged:

This has inspired many a conversation over the years, however as-is we know we have no shot of merging this. Conditional types have too many drawbacks for it to be OK to introduce them in control flow (like being very hard to relate correctly).

So for now it's not possible.


There are, of course, workarounds, the easiest of which is just to use a type assertion. I won't go into other possibilities here since it's mostly out of scope for the question.

Playground link to code

  • Related