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