Killing null: Option and Maybe for the Billion-Dollar Mistake (Part 4 of the Functional Programming Series)

Part 4 of the Functional Programming in Practice series. Parts 1 through 3 built pure pipes, composition, and recursion. Now we use all of it to remove the single most common crash in software.

Hey there, Coding Chefs! 👨‍💻

Quick question. What is the most common runtime error you have ever seen? Across every language, every codebase, every junior and senior engineer? I will bet money it is some flavor of this:

TypeError: Cannot read properties of undefined (reading 'name')

You expected a value. You got null or undefined. Your code reached for a property on nothing, and the whole thing fell over. The man who invented the null reference, Tony Hoare, has publicly called it his "billion-dollar mistake," because of how much it has cost the industry in crashes and bugs over the decades.

In functional programming, we do not have the null value the way you have seen it traditionally. That sounds extreme, so let me show you what is actually wrong with null, and then the clean way to model "there is no value here" that ends the crash for good. By the end you will understand Option (also called Maybe), you will know why "this type has exactly one more value than that one" is the exact tool that fixes this, and you will see why ?. and if (x == null) ladders are a patch where Option is a cure. Let's get cracking.

What Is Actually Wrong With null

Let's set up two functions to see the real problem. Recall from the foundation that a Total function is defined for every input, and a Partial function has a hole, some input it cannot handle.

const divide2 = (n: number): number => 2 / n; // partial: blows up conceptually at 0
const increment = (n: number): number => n + 1; // total

divide2 is partial. It is not honestly defined when n is 0. We know functional programming only wants total functions, so we need to make divide2 total. The first idea everyone has is to return null when the input is bad. That is literally why null exists, right?

const divide2 = (n: number): number | null => (n === 0 ? null : 2 / n);

Now here is the poison. Say a different engineer, on a different team, wrote increment. Its entire job is to add one to a number. But if your language lets null slip into the number type, then the author of increment is now forced to think about null too, even though null has nothing to do with incrementing. To safely compose divide2 and increment, every function downstream has to account for a null it never asked for.

You might say, fine, whoever wrote increment should just handle it correctly. And my honest answer is a question: are you always as careful as you should be when you code? Every single time? Because missing one null check leads to exactly the crash we opened with. The compiler often cannot save you, because null can masquerade as almost any type. Worse, other engineers build on top of your unhandled null and create more bugs on top of it. This is how one missing check becomes a cascade.

So the lesson everyone learns is "null is bad." But hold on. Null by itself is not really the villain.

The Real Fix: Stop Letting Your Types Lie

Here is the reframe that changes everything. We need some concept for "absence of a value." There is nothing wrong with naming that concept. The actual problem is that languages let null sneak into types that should not contain it. When you declare something an Integer, you mean an integer. Why should null secretly be a valid integer?

Go back to the foundation: a type is a set of values. The number type should be the set of numbers, full stop, with no null hiding inside it. If we want a type that includes the possibility of absence, we should say so explicitly and give that type its own name.

So let's invent a type that is "an integer, or nothing." Call it MaybeInteger. Notice its cardinality (the count of values in the set, from the first part): MaybeInteger has exactly one more value than Integer, the extra one being "nothing." That "one more value" framing is the whole trick. We are being precise about absence instead of smuggling it in.

But we do not want to hand-write MaybeInteger, MaybeString, MaybeUser, and so on. We want to abstract it to any type. When you hear "any type," think generics. We want a type constructor that takes a type and returns "that type, or nothing." That constructor is called Option (the other common name is Maybe).

Building Option

An Option of A is one of two things: a wrapper holding a value (call it Some), or a marker for absence (call it None). Other languages name these Just and Nothing, same idea.

We add a tag to each so we can tell them apart at runtime:

type Some<A> = { tag: "some"; value: A };
type None    = { tag: "none" };
type Option<A> = Some<A> | None;

Then a couple of helpers to construct them. some lifts a real value into an Option, and none is the single shared absence value:

const some = <A>(value: A): Option<A> => ({ tag: "some", value });
const none: Option<never> = { tag: "none" };

// a type guard so we can narrow at runtime
const isNone = <A>(o: Option<A>): o is None => o.tag === "none";

A small note on none. It is a singleton, one shared value representing absence everywhere. Functional programming does not dictate how you implement Some and None internally. You could use a JavaScript Symbol for uniqueness, or plain tagged objects like above. The tagged-object approach is exactly how fp-ts, one of the best-known functional libraries for TypeScript, models it, which is why I am showing it that way.

Now rewrite divide2 to be honest:

const divide2 = (n: number): Option<number> =>
  n === 0 ? none : some(2 / n);

This function is now total. Every input maps to a real output: a Some for good inputs, a None for zero. There is no hidden null, no exception, no lie in the type signature. Anyone reading Option<number> knows immediately that absence is a real possibility they must handle. The type tells the truth.

The New Problem (and the Cliffhanger)

We made divide2 total and honest, but we created a fresh problem. Watch:

const divide2 = (n: number): Option<number> => (n === 0 ? none : some(2 / n));
const increment = (n: number): number => n + 1;

divide2 now outputs Option<number>. But increment expects a plain number. The output type of the first pipe no longer matches the input type of the second. We know from composition that means they no longer compose directly. We cannot just pipe(divide2, increment) anymore, because increment does not know what to do with an Option.

There is a clean and genuinely beautiful way to solve this, where you teach yourself to run a normal function "inside" the Option without unwrapping and rewrapping by hand every time. But we are not there yet, that is the Functor part later in this series. For now, here is the honest interim solution: a small function that checks the tag and handles each case.

const divideThenIncrement = (n: number): Option<number> => {
  const result = divide2(n);
  if (isNone(result)) return none;        // nothing to increment, stay None
  return some(increment(result.value));   // unwrap, increment, rewrap
};

divideThenIncrement(2); // some(2)  -> (2/2) + 1
divideThenIncrement(0); // none     -> never crashed

Notice what happened to the zero case. It flowed through as none and nobody crashed. The absence was carried along safely instead of exploding. That manual unwrap-check-rewrap is tedious, and the whole reason the later Functor part exists is to delete that boilerplate. Hold the thought.

Option Versus TypeScript's Own Nullability

Now an honest question a sharp reader will ask: TypeScript already has strictNullChecks and union types like string | undefined. Doesn't that solve null already? Why do I need Option?

It is a fair point, and the answer is nuanced. With strictNullChecks on, TypeScript does force you to handle the undefined case, and that is genuinely good. For a lot of everyday code, string | undefined with the compiler nagging you is enough, and reaching for a full Option type would be overkill. I want to be honest about that rather than sell you a library you do not need.

Option earns its keep when you want more than a yes-or-no presence check. It gives you a consistent, composable container with a set of operations that work the same way across your whole codebase: a getOrElse to supply a default, a fromNullable to convert messy null/undefined at the boundary into a clean Option, and the map and chain operations (coming later in the series) that let you build long pipelines that automatically short-circuit on absence without a single manual if. When absence threads through many steps, Option keeps the pipeline clean. When it is a single check at one spot, native nullability is fine. Use the right tool.

There is also a conceptual distinction worth planting now. Option models absence: there is no value, and it does not say why. Sometimes that is exactly right (a user has no middle name). But sometimes you need to know why a value is missing, an actual error with a message. Option cannot carry that reason. That gap is the entire subject of the next part.

A Real Example: Pulling an Optional Field

Let's ground it. Say you fetch a user from an API and want their billing email, which may or may not be set. The reflexive code is a chain of guards:

// the patch
const billingEmail =
  user && user.billing && user.billing.email ? user.billing.email : "no email on file";

With Option and a fromNullable helper that converts a possibly-null value into an Option, plus a getOrElse to land on a default, the intent reads cleanly and the absence is explicit at every hop:

const fromNullable = <A>(a: A | null | undefined): Option<A> =>
  a == null ? none : some(a);

const getOrElse = <A>(fallback: A) => (o: Option<A>): A =>
  isNone(o) ? fallback : o.value;

// once we have map/chain (later part), this becomes a clean pipeline.
// the point for now: absence is a value you carry, not a landmine you step on.

The difference is small in two lines and enormous across a real codebase. Every place a value might be missing, the type says so, the compiler enforces it, and you can never again forget the check, because forgetting it will not compile.

The Honest Conclusion

Null is the billion-dollar mistake because languages let it hide inside types that should never contain it, which forces every function downstream to defend against an absence it never agreed to handle. The fix is not to be more careful with null. The fix is to make absence a real, named, visible thing in the type system. That is Option: a type constructor that wraps "a value of type A, or nothing," with exactly one more value than the type it wraps.

The moment a function returns Option<number> instead of number | null, it stops lying. Absence becomes a value you carry through your pipelines on purpose, handled once, never forgotten. The cost is a little wrapping and unwrapping, which the Functor part will largely automate away.

And know when to reach for it. A single presence check is fine with native nullability. A chain of maybe-missing steps is where Option shines.

Null lets your types lie about what they contain. Option makes them tell the truth, and a type that tells the truth is one the compiler can defend for you.

Next up: Option tells you a value is missing but never why. A failed validation should say what failed. Next we meet Either, which turns errors into ordinary values you can see in the type signature, and we connect it to a pattern you may already be using without knowing its real name.