Make your own Optionals

by: Ethan McCue

This is java.util.Optional.

I took out all the comments and did a little reformatting, but this is the entire class. Just around 150 lines managing one nullable field.

Take a minute to read or skim it before moving on.

public final class Optional<T> {
    private static final Optional<?> EMPTY =
            new Optional<>(null);
    
    private final T value;
    
    public static<T> Optional<T> empty() {
        @SuppressWarnings("unchecked")
        Optional<T> t = (Optional<T>) EMPTY;
        return t;
    }
    
    private Optional(T value) {
        this.value = value;
    }
    
    public static <T> Optional<T> of(T value) {
        return new Optional<>(Objects.requireNonNull(value));
    }

    @SuppressWarnings("unchecked")
    public static <T> Optional<T> ofNullable(T value) {
        return value == null ? (Optional<T>) EMPTY
                             : new Optional<>(value);
    }
    
    public T get() {
        if (value == null) {
            throw new NoSuchElementException("No value present");
        }
        return value;
    }
    
    public boolean isPresent() {
        return value != null;
    }
    
    public boolean isEmpty() {
        return value == null;
    }
    
    public void ifPresent(Consumer<? super T> action) {
        if (value != null) {
            action.accept(value);
        }
    }
    
    public void ifPresentOrElse(
            Consumer<? super T> action, 
            Runnable emptyAction
    ) {
        if (value != null) {
            action.accept(value);
        } else {
            emptyAction.run();
        }
    }
    
    public Optional<T> filter(Predicate<? super T> predicate) {
        Objects.requireNonNull(predicate);
        if (!isPresent()) {
            return this;
        } else {
            return predicate.test(value) ? this : empty();
        }
    }
    
    public <U> Optional<U> map(
        Function<? super T, ? extends U> mapper
    ) {
        Objects.requireNonNull(mapper);
        if (!isPresent()) {
            return empty();
        } else {
            return Optional.ofNullable(mapper.apply(value));
        }
    }
    
    public <U> Optional<U> flatMap(
         Function<? super T, ? extends Optional<? extends U>> mapper
    ) {
        Objects.requireNonNull(mapper);
        if (!isPresent()) {
            return empty();
        } else {
            @SuppressWarnings("unchecked")
            Optional<U> r = (Optional<U>) mapper.apply(value);
            return Objects.requireNonNull(r);
        }
    }
    
    public Optional<T> or(
            Supplier<? extends Optional<? extends T>> supplier
    ) {
        Objects.requireNonNull(supplier);
        if (isPresent()) {
            return this;
        } else {
            @SuppressWarnings("unchecked")
            Optional<T> r = (Optional<T>) supplier.get();
            return Objects.requireNonNull(r);
        }
    }
    
    public Stream<T> stream() {
        if (!isPresent()) {
            return Stream.empty();
        } else {
            return Stream.of(value);
        }
    }
    
    public T orElse(T other) {
        return value != null ? value : other;
    }
    
    public T orElseGet(Supplier<? extends T> supplier) {
        return value != null ? value : supplier.get();
    }
    
    public T orElseThrow() {
        if (value == null) {
            throw new NoSuchElementException("No value present");
        }
        return value;
    }
    
    public <X extends Throwable> T orElseThrow(
            Supplier<? extends X> exceptionSupplier
    ) throws X {
        if (value != null) {
            return value;
        } else {
            throw exceptionSupplier.get();
        }
    }
    
    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }

        return obj instanceof Optional<?> other
                && Objects.equals(value, other.value);
    }
    
    @Override
    public int hashCode() {
        return Objects.hashCode(value);
    }
    
    @Override
    public String toString() {
        return value != null
            ? ("Optional[" + value + "]")
            : "Optional.empty";
    }
}

Why does Optional exist

java.util.Optional was introduced in Java 8 alongside the Stream API. Its raison d'etre is to make coders explicitly consider what to do when using methods like findFirst on a potentially empty Stream.

// Explicitly throws 
int valueOne = list
    .stream()
    .map(x -> x + 1)
    .filter(x -> x % 2 == 0)
    .findFirst()
    .orElseThrow()
    
// Explicitly uses a default value
int valueTwo = list
    .stream()
    .map(x -> x + 1)
    .filter(x -> x % 2 == 0)
    .findFirst()
    .orElse(0)

The deficiency it targets is in the interaction between null and "method chaining style". When there are so many method calls stacked up, it is hard for people to remember to handle cases like null return values.

So with streams poised to encourage method chaining, Optional was needed to make that API not lead to hidden bugs.

What's wrong with Optional?

Nothing really.

The core tension that leads to so much discourse is that there is no way to represent null in Java's type system. Whether from lived experience or religious fervor, folks tend to be afraid of an unaccounted for null.

Because Optional is in the standard library and explicitly represents "absence or presence", it is extremely tempting to just replace every nullable thing with an Optional<T>.

Doing this can lead to code that sucks, especially if you try to avoid null for local variables.

// Some might try to use isPresent()/get() to avoid null
Optional<String> nameOpt = f();
Optional<Integer> ageOpt = g();

if (nameOpt.isPresent() && ageOpt.isPresent()) {
    var name = nameOpt.get();
    var age = nameOpt.get();

    System.out.println(
        name + " is " + age + " years old"
    );
}
// Others might try to map/flatMap.
Optional<String> nameOpt = f();
Optional<Integer> ageOpt = g();

nameOpt
    .flatMap(name -> 
        ageOpt.map(age -> {
            System.out.println(
                name + " is " + age + " years old"
            );
        }))
// But its questionable what's gained over null.
String name = f().orElse(null);
Integer age = g().orElse(null);

if (name != null && age != null) {
    System.out.println(
        name + " is " + age + " years old"
    );
}

But this is honestly fine.

Yes, the Optional will use up more memory and perform a bit worse than the equivalent code with null. Yes, code written with isPresent/get/orElseThrow or map/flatMap can be a bit crusty. Yes, it wasn't intended to be a field or a method parameter. There are a lot of bike sheds to build and "best practices" to get into internet fights over.

But jspecify is poised to give standard nullability annotations and tooling to augment the type system with them. Project Valhalla is considering giving a way to express null restricted storage. In the fullness of time, the core tension that leads to this "overuse" seems like it will be resolved.

The problem with both Optional and null is that they only convey that some data might be absent and not what being absent implies.

The Meaning of Absence

Say you were writing a program which had to record peoples' first and last names for legal reasons. Users can still sign up, but they will need to give that information before continuing on to other parts of the app.

Today you might see Optional being used to represent that.

import java.util.Optional;

record Person(
        int id,
        Optional<String> firstName,
        Optional<String> lastName
) {}

In the near future, maybe a @Nullable annotation.

import org.jspecify.annotations.Nullable;

record Person(
        int id,
        @Nullable String firstName,
        @Nullable String lastName
) {}

In both cases - null and an empty Optional - an absent value implies that you have not been given that information yet.

You can use this to know when to stop a user and ask them for their name.

import java.util.Optional;

record Person(
        int id,
        Optional<String> firstName,
        Optional<String> lastName
) {
    boolean shouldAskForInfo() {
        return firstName.isEmpty() || lastName.isEmpty();
    }
}
import org.jspecify.annotations.Nullable;

record Person(
        int id,
        @Nullable String firstName,
        @Nullable String lastName
) {
    boolean shouldAskForInfo() {
        return firstName == null || lastName == null;
    }
}

Now, consider Madonna. Madonna does not have a last name. If a null or empty value in the lastName field means "not provided", you have no way to directly represent "known to not exist."

// Need to ask Bob for his last name still
var bob = new Person(1, "Bob", null);

// Shouldn't ask Madonna for anything
var madonna = new Person(2, "Madonna", null);

Using an empty string is tempting, but if you do that you will have the same problem that null currently has. By having a "special" value not expressed in the type system, you are liable to forget to check for that special value.

// Empty string can be a sentinel
var madonna = new Person(2, "Madonna", "");

// But if you forget that it is special
// you might give Madonna a subpar user experience
var welcome = "Hello " 
        + person.firstName()
        + " "
        + person.lastName()
        + "!";

// "Hello Madonna !"
// She'll notice. She'll hate you.

The reality of our fictional data model is that we have three distinct cases.

  1. We have not been given a last name.
  2. We have been told there is no last name.
  3. We have been given a last name.

The most convenient tool we have for representing this sort of situation is a sealed interface.

sealed interface LastName {
    record NotGiven() implements LastName {}
    record DoesNotExist() implements LastName {}
    record Given(String value) implements LastName {}
}

Now when a LastName has an absent value, we can know whether that is because it doesn't exist or we just haven't been told.

import org.jspecify.annotations.Nullable;

record Person(
        int id,
        @Nullable String firstName,
        LastName lastName
) {
    boolean shouldAskForInfo() {
        return firstName == null 
                || lastName instanceof LastName.NotGiven;
    }
}

And we can properly represent Madonna.

// Need to ask Bob for his last name still
var bob = new Person(1, "Bob", new LastName.NotGiven());

// Shouldn't ask Madonna for anything
var madonna = new Person(2, "Madonna", new LastName.DoesNotExist());

// Joe is all set
var joe = new Person(3, "Joe", new LastName.Given("Shmoe"));

Optional and null let you represent exactly 2 possibilities, a sealed hierarchy lets you represent 2 or more possibilities. The reason I'm using the Madonna example is that it is a straw-man where you want to represent 3 distinct possibilities.

My bold claim is that even when there are only 2 possibilities you should still consider making your own class instead of using Optional or null.

Both @Nullable String firstName and Optional<String> firstName do not directly convey what it means if the data is missing. Its just "absent." The fact that it means you haven't been told is context external to your domain model.

It's a similar problem to primitive obsession. Because null and Optional are there and fit the "shape" we want we gravitate to them.

What if instead of that we were to make our own "optional" class.

sealed interface FirstName {
    record NotGiven() implements FirstName {}
    record Given(String value) implements FirstName {}
}

So here FirstName is identical in spirit to an Optional<String>, but with the benefit of us being able to give a name to the situation where there is no value. It's not empty or present, we were either given a first name or we weren't.

With pattern matching you will be able switch over these two situations.

switch (person.firstName()) {
   case FirstName.NotGiven _ ->     
        System.out.println("No first name");
   case FirstName.Given(String name) -> 
        System.out.println("First name is " + name);
}

And part of the reason I put all the code for Optional at the top was to impress upon you how trivial it would be to add any of those helper methods to a class you made yourself.

sealed interface FirstName {
    record NotGiven() implements FirstName {
        @Override
        public String orElse(String defaultValue) {
            return defaultValue;
        }
    }
    record Given(String value) implements FirstName {
        @Override
        public String orElse(String defaultValue) {
            return this.value;
        }
    }
    
    String orElse(String defaultValue);
}
var name = person.name().orElse("?");

That's it. That's the thesis.

If you are spending time modeling your domain objects, consider making your own versions of an Optional class. You can choose names which more align with your domain, adapt to more varied situations, and the boilerplate for doing so is at a historic low.

I will admit that if you have a huge number of fields with potentially missing data this can be more trouble than its worth. I still think its worth considering.


<- Index