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.
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), at least before recent null-checking contexts, 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.
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.
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)
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.
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, since C# 8.0, the language has supported nullable reference types where the compiler can optionally be configured to behave like Typescript and not allow null by default. Much extant C# code is still written without this functionality, so what is written here only applies in those cases.