Skip to content

Instantly share code, notes, and snippets.

@butaud
Created September 16, 2022 22:21
Show Gist options
  • Save butaud/a1cad04661928a281c625361637b970a to your computer and use it in GitHub Desktop.
Save butaud/a1cad04661928a281c625361637b970a to your computer and use it in GitHub Desktop.
How to think about nullability in Typescript

How to think about nullability in Typescript

A lot of time people approach Typescript with the mindset that it's halfway between an untyped language like Javascript and a traditional statically typed language. That might be true in some ways, but when it comes to nullability, Typescript (the way it's typically used) is actually much more strict/expressive than traditional languages like C, Java, and C#, and you might be led astray if you don't take that into account.

Javascript vs C# vs Typescript

In Javascript, values are completely untyped and you can of course assign any value to any variable or parameter, including null and undefined. There is no compiler, and if you want your code to be protected against invalid values you will have to write your own runtime checks.

In C# (and other traditional statically typed languages), any reference type accepts null. This goes back to C where a reference type value is represented with a pointer, and null was just a syntactic sugar for setting the pointer to 0. If you don't want to consume null values, you will have to write runtime checks to enforce this - the compiler won't enforce this for you.

In Typescript, null and undefined are separate types and cannot be assigned to any value that is not explicitly typed to accept them. If your code doesn't want to consume null values, it can set types accordingly and the compiler will enforce that any caller must not pass in null values.

Why this matters

In Javascript and C#, nullability is not a choice - your code must handle nullable values whether it wants to or not. In Typescript, nullability is a choice, and you should only type your code to accept nullable values if there's some reason why a null or undefined is a reasonable value. Nullability is an expression of what you want your code to do, not a boilerplate requirement to avoid runtime errors.

Example

Let's look at a hypothetical example. Suppose we have a class called ConversationService which has a method to send a message to another user. We might type the method like this:

sendMessage(recipient: string, message: string)

It probably doesn't make sense to make either of the parameters here nullable - there is no case in which we want to send a message but we don't have the content of the message or the recipient, so our implementation of the method shouldn't have to concern itself with how to handle the cases of null parameters.

But let's say that the message can also optionally have a subject/title, like a Teams channel post. Now part of the logic of our method implementation is going to be decorating the resulting message with a title if applicable, and a nullable parameter is a perfect representation of this optional behavior. So we can include a nullable parameter like this:

sendMessage(recipient: string, message: string, title: string | undefined)

Type coercion

In an ideal world, we design our type system thoughtfully from top to bottom, and there's never a point at which we need to convert a value from one type to another unless there's meaningful logic to handle at that point. In practice, this isn't what happens, because the codebase we work in is huge, and because sometimes we interact with third party tools or libraries and can't change the types at will. So what do you do when you have a nullable value, and the function you need to call expects a non-nullable value? There's no single right answer, but here are some things you should think through when you face this situation:

  • Why is the value I have now nullable? Are there meaningful cases where it should be null?
  • If there are meaningful cases where it can be null:
    • Is there a default value that I should be supplying?
    • Should I abort with an error or do something different?
    • Should the function I am calling actually handle the null case? Should I change the type and the logic there?
  • If there are no meaningful cases where it can be null:
    • Does anybody actually pass a nullish value, or can I just change the type declaration that I have here?
    • If someone does pass in a null or nullable value, can we push this consideration up to that level, or is it going to be impractical?

It's rarely a good idea to handle this problem by coercing your nullable value into a "dummy" value, like an empty string or placeholder integer value. This is because the code that receives the value will have a harder time noticing that it's not a real value - it will require explicit runtime checks. If it needs to do this, it should just accept nullable values to make the cases more explicit.

Final notes

Javascript (and thus Typescript) has distinct null and undefined values. Semantically the differences between them are very subtle. In practice, null can have slightly more memory overhead than undefined in some usage. For that reason and for simplicity, many recommend using undefined exclusively when possible. However there are times where interacting with a third party library (e.g. React or the GraphQL type generator) requires using null. If you need to take in a value that can be null, you can generally safely convert it to undefined with <value> ?? undefined.

Additionally, I believe that C# has some features added in the last decade or so to make nullability more explicit - I'm not familiar with these, so some of what I said about C# may be out of date, but it mostly still applies to that class of languages.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment