assert of null or undefined is not picked up on in nested function

Viewed 169

I have this function:

function assertNonNullish<TValue>(
  value: TValue,
  error?: Error
): asserts value is NonNullable<TValue> {
  if (value === null || value === undefined) {
    error ??= new Error("Something does not exist");
    throw error;
  }
}

This is supposed to check if something is not null nor undefined. If I use this function typescript also knows that something is not null nor undefined.

However when I use this function in another function like so:

function has_email() {
  const email = localStorage.getItem('email')
  assertNonNullish(email, new Error("email does not exist"));
}

and then use the has_email function to check the argument of another function:

function print_email(email: string) {
  console.log(email);
}

has_email() // Throws if email is not available but ts doesn't pick up on this
const email = localStorage.getItem('email')

print_email(email)

It seems like TypeScript can not figure out that running has_email() would be the same as assertNonNullish(localStorage.getItem('email'))

Is there any way to let TypeScript know? I would like to do it this way because I have to check state which has to be loaded from indexDB and I first have to await that.

1 Answers

The TypeScript compiler is, unfortunately, unable to track what's going on in your code well enough to provide the typings you expect. Your example code is currently making two separate calls of the form

const email1 = localStorage.getItem('email');
const email2 = localStorage.getItem('email');

And it is your expectation that the value of email1 and email2 will be the same, and thus they should have the same type (e.g., if email1 is not nullish then neither is email2). But TypeScript doesn't have a great way to encode such an expectation; checking the type of email1 won't cause the compiler to conclude anything whatsoever about subsequent behavior of localStorage.getItem(). And in fact I'm not sure you should conclude much either; if the two calls are in the same thread and there is no intervening storage mutation then maybe you can say that the second call will return the same value as the first. But in general there's no such guarantee.


Let's remove that problem by having a single call to localStorage.getItem('email') and a single const variable named email whose value will therefore always be the same no matter when you check it:

const email: string | null = localStorage.getItem('email');

function has_email() {
  assertNonNullish(email, new Error("email does not exist"));
}

has_email();

Now it is absolutely 100% guaranteed that any code that runs after has_email() will have access to a non-null email variable. But, the compiler still doesn't see it:

email.toUpperCase(); // error! Object is possibly null

Why not?


This is a general limitation of TypeScript, as described in microsoft/TypeScript#9998. When you check or assert the type of some value so as to narrow its apparent type, the compiler uses control flow analysis to keep track of where and when such a narrowing should apply. But this analysis only works inside a single function scope. It does not cross function boundaries. That means any narrowing inside a function will be inaccessible from outside, and vice versa. In our particular example, the narrowing of email that occurs inside the body of has_email() has no effect on any code outside, and we get an error.

This limitation can't be easily remedied. As a human being I can imagine running the above code twice, once assuming the email variable of type string | null is a string, and another time assuming it is null. In the former case everything is fine, and in the latter case you never reach any problematic line. For a union of two members and a has_email() function that's called once, this is easy enough to do. But it just doesn't scale. Most functions are called multiple times, and if you have multiple union-typed variables you would need to simulate every possible combination of values for every function call. I once suggested at microsoft/TypeScript#25051 that you could ask for this behavior on an opt-in basis (e.g., "please only do this kind of analysis for the email variable), but it was declined; this sort of thing needs to happen automatically or not at all.


There's also no concept of an assertion function or type predicate where the value whose type you are narrowing is not one of the parameters to the function (or the this context of a method). You can't annotate has_email(): asserts email is string, since email is not one of the parameters to has_email(). So again, there's no way to tell the compiler that has_email() has any implications on the state of email.


For these reasons, you need to work around it.

You can't keep your algorithm the same without losing some type safety. You can suppress the error with type assertions:

// assertion 
email!.toUpperCase(); // okay

but that suppresses the error even if you forget to call has_email() first.

Or, if you care more about type safety guarantees than about your particular algorithm, you could refactor. Currently you are assigning email in one place and then checking it with has_email() in another place, and leaving open the possibility that you can access email where it could be null. Instead, we can write a version where the assignment and type guarding happens together:

function getDefiniteEmail(): string {
  const email = localStorage.getItem('email');
  assertNonNullish(email);
  return email;
}

const email = getDefiniteEmail();
email.toUpperCase(); // okay

Now when you call getDefiniteEmail() you know you will get a string back and not null; it's already been checked! This sidesteps the entire problem of control flow analysis crossing function boundaries, because we've encoded the state we care about in the return type of getDefiniteEmail().

Playground link to code

Related