Simple Life and Good Code

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:

  1. Use both null and undefined depending on the context.
  2. Only use undefined and reach out for null only when you are forced to.
  3. Only use null and reach out for undefined 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:

  1. We don’t know a value and want to use default one.
  2. 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:

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.

We have few choices how to treat undefined value during serialisation.

  1. 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.

  1. 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.

  1. Encode undefined in JSON.

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:

// (user: User) => string | undefined
const getUserMiddleName = (user: User) => {
  if (user.middleName) {
    return user.middleName;
  }
};
// TS error: Not all code paths return a value.(7030)
const getUserMiddleName = (user: User) => {
  if (user.middleName) {
    return user.middleName;
  }
};
// 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.