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
null
andundefined
depending on the context. - Only use
undefined
and reach out fornull
only when you are forced to. - Only use
null
and reach out forundefined
only 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" } // OK
Personally 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 error
Second 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); // null
I 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") // noMaxWidth
Usage 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 noUncheckedIndexedAccess
Most 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 | undefined
Most 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 | null
In 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
null
value from external API? Do we keepnull
value and break our resolution to only useundefined
? Do we convertnull
toundefined
or do we remove this property completely? - What do we do when we want to send an object with property with
undefined
value to external API? Do we remove this property or do we convertundefined
tonull
. We need to keep in mind that absence of the property and property withnull
value can have different meaning to the Backend.
We have few choices how to treat undefined
value during serialisation.
- Ignore properties with
undefined
value.
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
undefined
value 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
undefined
inJSON
.
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); // undefined
Nullish 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,
undefined
type is added to return type signature. Later when we use this function we will quickly notice that we are dealing with potentiallyundefined
value.
// (user: User) => string | undefined
const getUserMiddleName = (user: User) => {
if (user.middleName) {
return user.middleName;
}
};
noImplicitReturns
flag 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.