Final dispute over undefined vs null in JS/TS
Intro
It is said that null is “the billion dollar mistake”. If that is the
case, how can we live with two billion dollar mistakes in JavaScript, namely
null and undefined? The answer for me was to pick one and ignore
the other. Long time ago I decided to go with undefined wherever possible
and honestly, I’ve been quite happy with my choice. I don’t know why, but I
started to think that I’m not alone and it is agreed by community standard.
Recently I had a discussion about this. I thought that I would just point out to
some articles on the internet with clear conclusion why we all should just use
undefined and ignore null. To my surprise I found as many opinions
on why we should do this as why we shouldn’t. What stroke me the most is that
even people I deeply respect for their knowledge had different views on this
matter. It made me think that maybe I was wrong many years ago. The best proof
of how split the community is, is this long
GitHub discussion with
great arguments from both sides. In following article I will try to distill an
ultimate list of arguments for and against each approach. I will try to be as
objective as I can but I can’t fully hide the fact that I’m biased toward
undefined camp.
Before we start with arguments, we need to settle what choices do we have. There are three schools of thoughts:
- Use both
nullandundefineddepending on the context. - Only use
undefinedand reach out fornullonly when you are forced to. - Only use
nulland reach out forundefinedonly when you are forced to.
I won’t dive deep into technical differences between null and
undefined. There is a great article about it
undefined vs null revisited
by Dr. Axel Rauschmayer. I want to focus purely on arguments on which one we
should pick for our project.
What I think is often missed in discussions about null and
undefined is TypeScript. Many arguments make a lot of sense in pure
JavaScript, but with type checker behind out backs they start to be redundant.
Especially with strictNullChecks flag enabled. I will be looking more from
TypeScript perspective.
Quick definition
Before we start with arguments let’s quickly check the official definitions from ECMAScript 2023 Language Specification. I will reference it few times during article.
null - primitive value that represents the intentional absence of any
object value
There are two crucial parts in this definition which are often mentioned during
the discussion. The first is about being intentional. null never appears
without us typing it. When we see null we are sure that someone
deliberately used it earlier in the code (or in library we use). The second part
which is often missed says that null represents absence of object
value. The idea behind null was to mimic the behaviour from other
languages (mainly Java) where null is used with reference types and
can’t be used with value (primitive) types. Well, in JS nothing stops us from
using it everywhere, but per definition, we should only used it with objects
(reference types).
undefined - primitive value used when a variable has not been assigned a
value
In a simplified mental model we can say that every time we don’t provide a value
where it is needed, JS will use undefined value under the hood. Most
common examples are when we create a variable without assigning value to it or
when we don’t return anything from a function. The problem is that we can’t be
sure if someone intentionally didn’t assign value or just forgot about it. Being
“unintentional”, in the opposition to intentionality of null, is also
main argument against undefined.
Theoretical definitions are nice but often the practice says something
different. Let’s go through the main use cases where we meet null or
undefined in person.
Unassigned variable
When variable is created without assigning value to it, it will hold
undefined value. The argument from null camp is that we should be
clear about our intention that variable doesn’t hold any value yet and assign
null to it in this case. In some circumstances it makes sense but not if
we follow functional programming with TypeScript.
In FP world where everything is immutable there isn’t much sense in creating a
variable without value in the first place. How often do we write
const x = null or const x = undefined? We strive to assign proper
value immediately when variable is created. In general it makes our code easier
to follow because we don’t need to jump between different lines of code to
understand what value variable has. It also helps TypeScript a lot so it can
immediately infer proper variable type for us. On the side note, I think this is
one of the main reasons why some people struggle with TS. It works the best when
variables don’t change their types after creation.
There are rare cases where it is hard to assign value during variable definition
e.g. when value comes in try/catch statement. I don’t see a problem of
using undefined in those situations. TypeScript doesn’t allow using
variable, if there is a chance that it doesn’t have assigned value. Basically we
are forced to explicitly extend variable type with undefined type, hence there
is no accidentally in it. In the end I don’t think it is an issue if we use
let and assigned value later as long as it is encapsulated in small block of
code, ideally a function.
interface User {
name: string
}
declare function getUser(): User
---
let user: User;
try {
user = getUser()
} catch {
// Missing catch so user variable may not have assigned value
}
// TS error: Variable 'user' is used before being assigned.(2454)
console.log(user?.name)
---
let user: User | undefined = undefined;
try {
user = getUser()
} catch {
// Missing catch so user variable may not have assigned value
}
console.log(user?.name)There is a small trick that I often use to avoid variables with temporal empty
value. Another reason why we are tempted with starting with let x is when
there is a more complex logic behind what value we want to assigned. We can try
to turn this logic into expression (e.g. instead of using if/else we can
use ternary operator ?:) but often it makes code less readable. What I
think works nicer and in every case is extracting this logic into separate
function (or using IIFE).
declere const something;
declere const somethingElse
---
let x;
if (something > 0) {
x = 1,
} else if (somethingElse > 0) {
x = 2;
} else {
x = 3
}
---
const x =
something > 0 ? 1 :
somethingElse > 0 ? 0 :
2;
---
const calcX = (something: number, somethingElse: number) => {
if (something > 0) {
return 1;
} else if (somethingElse > 0) {
return 2;
} else {
return 3;
}
}
const x = calcX(something, somethingElse);
Non existing property and property with nullish value
Another common argument against undefined is that there is very subtle
difference between property which doesn’t exist and property with
undefined value. It is inline with the premise that undefined can
be accidental and in turn cause bugs. For example by mistake we can forget to
set middleName property while creating User object.
type User = { middleName?: string };
const user: User = {};
console.log(user1.middleName); // undefined
const user: User = { middleName: undefined };
console.log(user.middleName); // undefined
if (user.middleName === undefined) {
// true for both users
}To be compatible with JS, when we defined a property with ? prefix,
TypeScript does two things. It makes property optional (it may not exist in an
object) and it can have undefined value (behind the scene TypeScript
creates a union PropertyType | undefined as property type). This
ambiguity was fixed with exactOptionalPropertyTypes compilation flag in
TypeScript 4.4. With this flag enabled, prefix ? only means that property
may not exist in the object, but if it exists it has to have a value. Of course,
if we want we can explicitly add that value can also be undefined.
// exactOptionalPropertyTypes: true
type User = { middleName?: string }
const user: User = {} // OK
const user: User = { middleName: undefined } // TS error
const user: User = { middleName: "John" } // OK
---
type User = { middleName: string | undefined }
const user: User = {} // TS error
const user: User = { middleName: undefined } // OK
const user: User = { middleName: "John" } // OK
---
type User = { middleName?: string | undefined }
const user: User = {} // OK
const user: User = { middleName: undefined } // OK
const user: User = { middleName: "John" } // OKPersonally I haven’t used exactOptionalPropertyTypes in any of my projects
yet. The only case where I found this ambiguity of undefined property
problematic is while serialising object to JSON but I will cover it later. In
most cases I would say that it is more convenient to have this shorter syntax
with double meaning to description optionality.
type User = { firstName: string; middleName?: string };
// When I create object literal it is faster to skip unwanted property
const user: User = {
firstName: "John",
};
// When value for property is computed, it is easier to assign undefined
const user: User = {
firstName: "John",
middleName: getMiddleNameOrUndefined(),
};
// Alternativies are very verbose
const middleName = getMiddleNameOrUndefined();
// Alternative 1
const user: User = {
firstName: "John",
};
if (middleName != null) {
user.middleName = middleName;
}
// Alternative 2
const user: User = {
firstName: "John",
...(middleName != null ? { middleName } : {}),
};If we really want property to be always intentionally initialised,
propertyName: Type | undefined still does the job regardless of
exactOptionalPropertyTypes flag.
Still, for my next project I curious to see how exactOptionalPropertyTypes
flag proofs itself in practice.
Function parameter default value and destructuring default value
For me this is singly the main reason to use undefined instead of
null. Function parameter default value and destructuring default value
are triggered only by undefined value.
const calcWidth = (maxWidth = 100) => {
console.log(maxWidth);
}
calcWidth(undefined) // 100
calcWidth(null) // TS error
---
const calcWidth = ({ maxWidth = 100 })) => {
console.log(maxWidth)
}
calcWidth({}) // 100
calcWidth({ maxWidth: undefined }) // 100
calcWidth({ maxWidth: null }) // TS errorSecond pattern is especially popular in React to create props with default
value.
TS rightly disallow passing null in those situations so we won’t
accidentally pass null hoping for default value to be triggered. I think
in pure JS this could be much bigger issue where we end up with null
instead of default value. Even though we are covered by TS, if we want to
leverage default values when working with null we are forced to write
unnecessary and ugly code that converts null to undefined just for
this.
const calcWidth = (maxWidth = 100) => {
console.log(maxWidth);
};
const maybeMaxWidth: number | null = null;
calcWidth(maybeMaxWidth ?? undefined);The argument could be made that it is a pros of null . We can use it as
explicit “switch off” value which doesn’t trigger default value. A function
could have two different flows:
- We don’t know a value and want to use default one.
- We know that value doesn’t exist and we want to pass this information to the function.
const calcWidth = (maxWidth?: number | null = 100) => {
console.log(maxWidth);
if (maxWidth === null) {
// do something special
}
};
calcWidth(undefined); // 100
calcWidth(null); // nullI find this distinction super vague and I would try to avoid having this
situation at all. I would be extremely surprised by the code that have different
flow for undefined and null value. If we can’t avoid it (rare
situation), I think a better option is to use an union type with explicit type
that represents different flow.
const calcWidth = (maxWidth?: number | "noMaxWidth" = 100) => {
console.log(maxWidth);
if (maxWidth === "noMaxWidth') {
// do something special
}
}
calcWidth(undefined) // 100
calcWidth("noMaxWidth") // noMaxWidthUsage in standard library, Web APIs and ecosystem
We don’t write our code in vacuum. I theory we could stop using one “non value”
but in practice we will be quickly forced to deal with it by standard library,
Web APIs or other libraries we are using. It shows luck of agreement in the
community and even some inconsistency in the language design. Let’s go through
the most common places where we can find null or undefined.
Standard collection like APIs returns undefined when item we want to
access doesn’t exist. It is also reflected in TypeScript types for functions
like Array.find and Map.get. With noUncheckedIndexedAccess flag
it also become true for accessing items via indexes in both arrays and records.
const arr = [1, 2, 3]
arr.find(x => (x === 9)) // number | undefined
arr[2] // number | undefined with noUncheckedIndexedAccess
---
const map = new Map<string, number>()
map.get("key") // number | undefined
---
const record: Record<string, number> = {}
record["key-1"] // number | undefined with noUncheckedIndexedAccessMost popular utility libraries also prefer to use undefined in all cases
where non value could occur.
// lodash
_.find(users, { age: 1, active: true }); // User | undefined
_.findKey(users, { age: 1, active: true }); // User | undefined
_.get(object, "a[0].b.c"); // number | undefined
// ramda
R.find(R.propEq("age", 18))(users); // User | undefined
R.path(["a", "b"], { c: { b: 2 } }); // number | undefined
// rxjs
users$.pipe(find((user) => user.age > 18)); // emits User | undefinedMost Web APIs, in contrast, use null for situations where something
doesn’t exist. When we search for null in lib.dom.ts there are 1081
results where for undefined it is just 48. It shows how widespread
null is in Web APIs. The most common examples are
// Storage API
localStorage.getItem("item-key"); // string | null
sessionStorage.getItem("item-key"); // string | null
// DOM API
document.getElementById("id"); // Element | nullIn the ere of web frameworks and libraries for everything we rarely have to
interact with Web APIs directly. In opposition to standard library (that is more
and more capable) and popular libraries like lodash which we use on a daily
basis.
JSON support
JSON is the easiest and most common way to serialise data in JavaScript. We
can’t avoid it in our applications. So we need to deal with a fact that
null is a valid JSON value whereas undefined is not. Usually
serialisation happens in two contexts:
- Inside single application. For example to store data in local storage or when
frontend and backend are one application like in
Next.js. In this case we fully control serialisation/deserialisation process and have more freedom. - To exchange data with external system/API. In this case we don’t control how
external system:
- deserialise data it receives from us
- serialise data it sends to us
The fact that undefined isn’t a valid JSON value poses a bunch of
questions how to handle it. Especially in a context of communication with
external systems.
- What do we do when we receive an object with property with
nullvalue from external API? Do we keepnullvalue and break our resolution to only useundefined? Do we convertnulltoundefinedor do we remove this property completely? - What do we do when we want to send an object with property with
undefinedvalue to external API? Do we remove this property or do we convertundefinedtonull. We need to keep in mind that absence of the property and property withnullvalue can have different meaning to the Backend.
We have few choices how to treat undefined value during serialisation.
- Ignore properties with
undefinedvalue.
That is how native JSON.stringify works. Interestingly, if
undefined is inside an array, it is converted to null. If we want
to alter default serialiser behaviour we can use second parameter called
replacer for this.
JSON.stringify({ foo: undefined }); // "{}"
JSON.stringify([undefined]); // "[null]"
JSON.stringify({ foo: undefined }, (key, value) =>
value !== undefined ? value : null
); // { foo: null }What I don’t like about this is that serialisation and deserialisation should be symmetric. The object we serialise should look exactly the same after deserialisation. Here we break this rule. In most cases it doesn’t matter, but still it would nice to keep this trait.
JSON.parse(JSON.stringify({ foo: undefined }) // {}OpenAPI Generator (the most
popular code generator for Swagger/OpenAPI specifications) converts not required
properties in specification to optional properties (with ?) in type
definitions. Under the hood it uses JSON.parse/JSON.stringify and
additionally converts null value to undefined during
deserialisation.
- Throw an error when when
undefinedvalue is encountered.
This is how default serialisation in Next.js work.
We need to manually take care of all properties with undefined value.
Depending on the context we can remove them or convert to null before
passing an object to serialiser.
We may want to use exactOptionalPropertyTypes to reduce the amount of objects
with properties with undefined value.
It is the safest method but also require the most work. Luckily serialisation/deserialisation usually can and should be implemented in one centralised place so we need to do it only one time.
- Encode
undefinedinJSON.
During serialisation we can include matadata which allows us to deserialise
property back to undefined. E.g.
superjson library does it for us. It
works only when serialisation and deserialisation happens in JavaScript and
inside single application. If we are sending/getting JSON from external API
(often written in different language which doesn’t have a notion of
undefined type) it isn’t feasible. This is a great option for
serialisation inside single application.
In the context of JSON serialisation usage of exactOptionalPropertyTypes
sounds like a good option. With this flag we can safely assume that lack of
property means that property doesn’t exist and property with undefined value
was intentionally created. During deserialisation null can be converted
to undefined and during serialisation undefined can be converted
to null without ambiguity. Missing property is naturally skipped.
In practice I haven’t worked with an API where there was a difference between
lack of property and property with null value but I can imagine that in
PATCH requests it can make a difference between not updating a property,
setting it to an empty value or removing it. We should be especially careful in
those cases if we decide to use undefined everywhere.
Interoperability with other languages
Our application can’t live in isolation. Sooner or later we have to integrate it
with other services. It can be a backend written in Java when we write
frontend application or SQL Database when we build fullstack application. The
case is that null exists in almost all programming languages and is the
common way to describe lack of value where undefined is a JavaScript
thing. In database we can have NULL value in the column and API can
return null value in response. We touched similar problem with “JSON
support” and the answer is the same. I think in most cases we can translate
null to undefined but it is a tradeoff.
This was also one of the main reasons why null was added to JavaScript
in the first place. To be compatible with Java, where null represents
empty reference to an object.
typeof undefined vs typeof null
undefined is a separate type in JavaScript type system, where
null is an object type. Based on the definition null “represents
the intentional absence of any object value”. Second part says that null
is supposed to be used for object (reference) values and not for primitives.
Hence typeof null returns "object". This may catch us off guard
when we use typeof operator to determine data type. Looking from this
perspective using null universally for all data types seems wrong. Some
people may argue that everything is an object in JavaScript. The answer more
subtle, but looking at typeof operator alone, it isn’t true.
Optional chaining .?
Optional chaining works with both null and undefined but it always
returns undefined when nullish value is encountered. If we want to stick
with null in our codebase, we will have to do extra work every time we
use optional chaining.
const user: User | null = null;
console.log(user?.name); // undefined
console.log(user?.name ?? null); // null
const user: User | undefined = undefined;
console.log(user?.name); // undefinedNullish coalescing ??
Nullish coalescing works that same way with null and undefined.
const user: User | null = null;
console.log(user ?? "no user"); // "no user"
const user: User | undefined = undefined;
console.log(user ?? "no user"); // "no user"Default return value from function
A function without return statement implicitly returns undefined.
It’s another argument that undefined could be accidental and returning
null would be more explicit and could prevent bugs. I think it is a valid
point in JavaScript but not in TypeScript. In TypeScript:
- Inferred return type always tells the truth. If we don’t return in a part of
our function,
undefinedtype is added to return type signature. Later when we use this function we will quickly notice that we are dealing with potentiallyundefinedvalue.
// (user: User) => string | undefined
const getUserMiddleName = (user: User) => {
if (user.middleName) {
return user.middleName;
}
};noImplicitReturnsflag makes it impossible to skip return statement when we already have one in our function. It forces us to not have return at all or have it in all code paths. It’s a good practice and TS catches missed returns immediately.
// TS error: Not all code paths return a value.(7030)
const getUserMiddleName = (user: User) => {
if (user.middleName) {
return user.middleName;
}
};- Providing explicit return type in a function signature removes any
accidentality. If return type doesn’t include
undefined, TS throws an error if we miss return statement. I think this is the best option and explicit return type is a good practice regardless our discussion about default return value. There is also a hidden performance benefit (link) 😉
// TS error: Function lacks ending return statement and return type does not include 'undefined'.(2366)
const getUserMiddleName = (user: User): string => {
if (user.middleName) {
return user.middleName;
}
};Using both null or undefined
Till now I focused more on the decision between using undefined or
null. But a considerably large group of developers claim that there is
nothing wrong with having both of them. Even more, they claim that it is better
that we have two nullish values because they allow us to describe different
scenarios. My problem is that there is no clear guidance when to use which. Even
when we say that null is for intentional absence of value, it is still
debatable when something is truly intentional and when not. I found opinion that
“I understand when to use null/undefined, the problem is with others” to
be quite bold. There is no guaranty that our understanding is the only correct
interpretation when language itself doesn’t give us unambiguous answer. After
reading a thread I suspect
that even two proponents of using both null and undefined sooner
than later will argue about what should be used when. In team environment it is
especially problematic. I’ve worked long enough to witness and participate in
never ending discussions in PRs about small matters like this. Our junior
teammates will be happy to have clear guidance what to do without trying to
guess what senior developers have in their minds.
Summary
It is hard for me to justify using null. The only use cases where it has
clear upper hand are JSON support and interoperability. In all other cases the
difference is negligible or undefined is way more practical. It feels
like language itself, with features like optional chaining and default values,
pushes us to use undefined and it is never a good idea to fight with the
language. Most of potential pitfalls related to undefined, like
accidentally not returning a value from the function or not initialising a
variable or a property, are no longer relevant with TypeScript. TS makes
undefined much more explicit and forces use to deal with it. Since I
start using undefined exclusively many years ago, I don’t recall any
accidents cause by using it instead of null. On the other hand I have one
less thing to worry about. No more questions like: Should I use undefined
or null in given case? Or what and why someone used in given situation?
In a team it is especially useful to have rules that are easy to follow by
everyone.