unModified()

Break stuff. Now.

How Java Can Fix Its NPE Problem

When another language's compiler option may hold the key

April 2, 2021

boy writing C# Photo by cottonbro from Pexels

I am currently obsessed with Java Optional because one day, I accidentally strayed into its source code and got my mind blown. I knew OpenJDK was public, it's just that I never bothered to look into it until now. From there, I decided to study the code, what makes it tick, what makes Optional so cool that it's being recommended everywhere.

Optional exists to avoid the dreaded NullPointerException (which we'll call "NPE" going forward) - the error we get when we try to access a member of an instance that ended up being null at runtime. Under the hood, Optional just does null-checks. But it does this through a very elegant API. It provides utility methods, allows chaining, and propagates type information correctly so that we will always know what to expect.

But looking at the docs for Optional, some APIs can actually NPE. It's throwing the very exceptions that we're trying to avoid! For example, of NPEs when given a null value. Or map() NPEs if we provide a null mapper. or() and flatMap() NPEs when the value is mapped to a null. Strange?

Well... not really.

To answer this weird behavior, we need to hop over to another language: TypeScript. In TypeScript, there's a compiler flag called --strictNullChecks. Without --strictNullChecks, null and undefined are valid values to any type. This is the root of all NPEs because we often write code assuming the value is always there when they may not. But with --strictNullChecks, null and undefined become their own types. This means we can no longer assign null or undefined to any type. And if we really have to, we have to explicitly do so, e.g. making a union type. Because of this, TypeScript can know which values can be null and warn us at compile time for any misuse.

// Without --strictNullChecks
const foo: string = 'Hello, World!'
const bar: string = null      // valid
const baz: string = undefined // valid

console.log(foo.length)
console.log(bar.length) // EXPLOSION!
console.log(bar.length) // EXPLOSION!

// With --strictNullChecks
const foo: string = 'Hello, World!'
const bar: string = null      // tsc will not compile
const baz: string = undefined // tsc will not compile
const qux: string | null = null
const quux: string | undefined = undefined

console.log(foo.length)
console.log(qux.length) // tsc tells us to deal with the null case
console.log(quux.length) // tsc tells us to deal with the undefined case

Back in Java, as far as I know, there is no equivalent for --strictNullChecks. This means null is still a valid value for any type to represent the absence of a value, e.g. String foo = null;. This is why we need Optional and, going back to the topic at hand, why Optional APIs throw NPEs from its APIs. That's because we're not just dealing with potentially nullable values here. The inputs of the various APIs can also be null too.

OurBusinessObject defaultValue = new OurBusinessObject('nope');
OurBusinessObject nullableValueFromDb = null;
Function<X, Y> dynamicMapper = null;

OurBusinessObject finalValue = Optional
  .ofNullable<OurBusinessObject>(nullableValueFromDb)
  .map(v -> v.getRelatedBusinessObject()) // won't run because empty
  .map(dynamicMapper)                     // EXPLOSION!
  .orElse(defaultValue);

In the above example, ourDynamicMapper can be null because it's a valid value for Function<X, Y>. From that point, it can wreak havoc in code because developers think it's a callable function without knowing it can be null (and null-checking every single value is a pain). But over in TypeScript with --strictNulLChecks, if we declared dynamicMapper as a nullable value and implemented map() to only accept a function, the compiler can determine ahead of time that dynamicMapper cannot be a mapper for map().

const defaultValue: OurBusinessObject = new OurBusinessObject('nope')
const nullableValueFromDb: OurBusinessObject | null = null
const dynamicMapper: <X, Y> (v: X) => Y | null = null

const finalValue = Optional
  .ofNullable<OurBusinessObject>(nullableValueFromDb)
  .map(v => v.getRelatedBusinessObject())
  .map(dynamicMapper) // null cannot be assigned to (v: X) => Y
  .orElse(defaultValue)

If Java can implement something similar to --strictNullChecks, that would be great. But with the stability requirements of the Java ecosystem and the amount of legacy code already out there, I doubt one can easily make such a big change. We can't rewrite all the Java code overnight to comply with this compiler option. Optional might probably be the best solution for now.