Introduction
In 2009, Tony Hoare, the computer scientist who invented the null reference back in 1965, publicly called it his "billion-dollar mistake". His reasoning: it was easy to implement, so it went into practically every mainstream language, and five decades later we are still paying for the "innumerable errors, vulnerabilities, and system crashes" it caused.
Given a function that might not find what it's looking for (say, finding a user by email), how do Python, TypeScript, Java, and Scala each represent the notion of "there might be nothing"?
Python: None is just an illusion
Python type hints let you write "this might not exist", but do not be fooled. The annotation is a note to the reader. Nothing enforces it at runtime, and the popular type checkers only catch a violation if somebody runs them, configured strictly, which most real codebases do not. That holds for either spelling of the annotation. Before Python 3.10 it is Optional[User] from the typing module:
from typing import Optional
def find_user(email: str) -> Optional[User]:
return db.query(User).filter_by(email=email).first()
user = find_user("ada@example.com")
if user is not None:
print(user.name)
else:
print("not found")
# print(user.name) # May throw "AttributeError: 'NoneType' object has no attribute 'name'"Python 3.10 added PEP 604, which lets you spell the same thing as User | None, no extra imports required. It's the more modern, now-idiomatic form of Optional[User]. The "old syntax" isn't deprecated and still works on any version, but PEP 604's syntax is what current style guides and linters (like ruff's pyupgrade rule) steer codebases towards. It changes nothing about the underlying problem, though:
def find_user(email: str) -> User | None:
return db.query(User).filter_by(email=email).first()
user = find_user("ada@example.com")
if user is not None:
print(user.name)
else:
print("not found")
# print(user.name) # Again, may throw "AttributeError: 'NoneType' object has no attribute 'name'"Either version, marking a field optional buys you little when the developer still has to write the check by hand before touching the value.
TypeScript: structural, no monadic composition
With strictNullChecks on, TypeScript refuses to let you use a possibly undefined value without narrowing it first. That puts it well ahead of Python: the safety is real and the compiler enforces it. What you do not get is composability. Chaining several "this might be missing" steps means narrowing at each one, rather than a single pipeline.
function findUser(email: string): User | undefined { /* ... */ }
function findAddress(user: User): Address | undefined { /* ... */ }
const user = findUser("ada@example.com");
// Every step needs its own guard: there's no single operation that
// chains "and then, if present, do the next possibly-missing thing":
const city = user
? (findAddress(user)?.city ?? "Unknown")
: "Unknown";Java: Optional, opt-in and easy to bypass
Java 8 added Optional<T>, but it's a library type you have to choose to use, layered on top of a language where every reference type is still nullable by default, with legacy APIs still returning nullable objects, and third-party libraries that are still using that same convention. Add to that, the fact that Optional itself ships a get() that undoes the whole point:
Optional<User> maybeUser = userRepository.findByEmail("ada@example.com");
// The safe way exists...
String name = maybeUser.map(User::name).orElse("Unknown");
// ...but so does this, and it compiles just as cleanly:
String name2 = maybeUser.get().name(); // throws NoSuchElementException
// if empty; Optional bought us
// nothing here.On top of that: nothing stops a method from being declared to return a plain, nullable User instead of Optional<User> in the first place; the language doesn't require you to reach for Optional at every boundary where a value might be absent.
Scala: Option as a first-class, composable type
Scala has no unchecked null in idiomatic code. A value that might be absent is represented by an Option[User] and, unlike Java's version, there's no plain nullable User return type sitting next to it as a tempting shortcut. The convention is total: if it might not exist, its type says so.
On top of that, Option exposes map and flatMap, so several actions that each return an Option compose into one chain.
def findUser(email: String): Option[User] = users.find(_.email == email)
def findAddress(user: User): Option[Address] = addresses.get(user.id)
def findCity(email: String): Option[String] = {
findUser(email)
.flatMap(findAddress)
.map(_.city)
}Every step in that chain (find the user, then find their address, then read the city) composes with flatMap/map exactly the way you'd chain .then() on a promise, and the whole thing short-circuits to None automatically the moment any step comes back empty. No need to write manual, deeply-nested, if(...) clauses:
def findUser(email: String): Option[User] = users.find(_.email == email)
def findAddress(user: User): Option[Address] = addresses.get(user.id)
def findCity(email: String): Option[String] = {
val user = findUser("ada@example.com")
var city = None
if (user.isDefined) {
val address = findAddress(user)
if (address.isDefined) {
city = Some(address.city)
}
}
city
}To be fair though, Option does expose an unsafe .get method, which throws an exception in case it is a None. However, compared to Java, the difference here is both cultural and tooling-enforced, on top of being structural.
Because the standard library returns Option everywhere, sticking to the convention is the path of least effort, and chaining optional actions together stops feeling like a chore. We could call .get, but we choose not to, because the resulting code reads worse. The option is still there for a tight CPU-bound loop where you want to shave off some cycles.
Then, you also have linters at your disposal (such as Wartremover), that commonly ban .get on Option outright in production code, precisely because the composable alternative is never more than one extra combinator away, so there's rarely a reason to reach for the unsafe path.
Why AI agents stop forgetting null checks
A forgotten null check is a sneaky category of bug, and we have seen it in AI-generated code across several languages (models have got better at this over the past couple of years, to be fair). An agent writes a function that calls find_user, then dereferences the result three lines later without checking, because Python did not stop it and the type hint is a comment as far as execution is concerned.
In Scala, that class of mistake is caught before the code runs: you cannot call .name directly on an Option[User], because there's no such method. You get immediately a compile error, not a 2am phone call. The agent is forced to either handle the None case or explicitly reach for the rare, discouraged, unsafe accessor. Either way, the decision becomes visible in the code, instead of invisible until it fails at runtime.
