Ryan Chandler

Published in PHP

Creating an `Option` Type in PHP

If you've ever worked with the Rust language, you've likely run into Option.

There is no concept of null or nil in Rust, nor is there any concept of optional parameters in function definitions.

This is one of the things that makes Rust's type system so powerful, reliable and, at times, annoying.

The Option type itself is actually just an enum (enumeration) that is defined as:

enum Option<T> {
    Some(T),
    None
}

In Rust, enums are able to hold values inside of variants. This is something that has been thought of in PHP land too.

Rust provides some global constructors for the Option type too. You don't need to type Option::Some(value) everytime, you can just type Some(value).

If you were to add an optional parameter to a function, it would look something like this:

fn hello(name: Option<String>) {
    let name = if name.is_some() {
        name.unwrap()
    } else {
        "World!".to_string()
    }

    println!("Hello, {}", name)
}

Again, the Option enum is generic so you need to provide the type of the wrapped value.

Calling unwrap() on the Option will return the inner value, in this case a String. If the Option is None, then the program panics and fails to run.

Why is this better?

The Option type is best used in situations where you sometimes return a value from a function (typically an object).

It's not uncommon to forget about the cases where no value, or null, is returned from the function.

By introducing an Option type you have to explicitly handle the None or null case, otherwise you won't be able to access the wrapped value.

Here's an example in a Laravel application:

$name = Auth::user()->name;

If the user is logged in this code will work fine. If the user isn't logged in, Auth::user() will return null and the ->name access will error out.

In PHP 8, you could handle this with the ?-> (nullsafe) operator but you would still end up with a null value in $name.

If the Auth::user() function used Option:

public function user(): Option
{
    return $this->user ? new Some($this->user) : new None;
}

To access the User, you would need to do something like this:

$user = Auth::user()->unwrap();

If None is returned from the Auth::user() function, the unwrap() method call will fail and throw an Exception because you can't unwrap a None value.

You have to explicitly consider the None scenario when working with Option.

Creating the type

We can start by defining an interface for our Option type that the Some and None types will implement.

I'll be writing PHP 8.0 compatible code. If you haven't upgraded already, you definitely should.

interface Option
{
    public function isSome(): bool;

    public function isNone(): bool;

    public function unwrap(): mixed;
}

These three functions are the bare essentials. You'll be able to determine if a type is None, Some and retrieve the internal value.

None

With this interface defined, we can go ahead and create our None type.

class None implements Option
{
    public function isSome(): bool
    {
        return false;
    }

    public function isNone(): bool
    {
        return true;
    }

    public function unwrap(): mixed
    {
        throw new \RuntimeException('Attempt to call `unwrap()` on `None` value');
    }
}

The None type is the simplest. The unwrap() method will throw a \RuntimeException as you can't unwrap a None value.

In PHP 8.1 you could replace the mixed return type with never as it always throws a \RuntimeException.

Some

The Some type has a little more going on. We need to define a __construct method to actually hold the value and also return that value from unwrap().

class Some implements Option
{
    public function __construct(
        protected mixed $value
    ) {}

    public function isSome(): bool
    {
        return true;
    }

    public function isNone(): bool
    {
        return false;
    }

    public function unwrap(): mixed
    {
        return $this->value;
    }
}

With these implemented, we can go ahead and write our first function that uses Option.

function sayHello(Option $name) {
    $name = $name->isNone() ? 'World' : $name->unwrap();

    echo "Hello, {$name}!";
}

sayHello(new None);         // Outputs "Hello, World!"
sayHello(new Some('Ryan')); // Outputs "Hello, Ryan!"

unwrapOr()

When you start using Option, you'll find yourself using ?: (ternaries) or if..else statements to set default values.

We can help ourselves out by introducing a new unwrapOr(mixed $or) method.

Let's first add it to our Option interface.

interface Option
{
    public function unwrapOr(mixed $or): mixed;
}

And then implement in our Some and None classes.

class Some implements Option
{
    public function unwrapOr(mixed $or): mixed
    {
        return $this->unwrap();
    }
}

The Some class should always hold a value, so we will never need to return the $or.

class None implements Option
{
    public function unwrapOr(mixed $or): mixed
    {
        return $or;
    }
}

The None class can never be unwrapped so we will always return the value of $or.

With this method in place, we can remove the ternary from our sayHello() function:

function sayHello(Option $name) {
    $name = $name->unwrapOr('World');

    echo "Hello, {$name}!";
}

Generics

PHP doesn't support runtime generics. There is no first-party syntax for them either.

We're very fortunate to have static analysis engines though, so we can use DocBlocks to imitate generic types.

Let's start by adding some annotations to your Option interface.

I'll be using annotations supported by PHPStan.

/**
 * @template T
 */
interface Option {
    // ...
}

This makes the Option type generic on T. To actually make T do something, we'll need to tell the analyser where T comes from.

Let's add some annotations to the Some type.

/**
 * @template T
 *
 * @implements \Option<T>
 */
class Some implements Option
{
    // ...
}

These class-level annotations will tell the analyser that the Some class is also generic and that the type of T in Some should also be used as the generic type of Option which we're implementing.

Since our unwrap() and unwrapOr() method return a mixed type inside of Some, we can't guarantee that any further method calls or property access is valid.

To tackle this we can add more annotations to those methods, as well as the property promotion in our constructor.

/**
 * @template T
 *
 * @implements \Option<T>
 */
class Some implements Option
{
    public function __construct(
        /** @var T */
        protected mixed $value
    ) {}

    /**
     * @return T
     */
    public function unwrap(): mixed
    {
        return $this->value;
    }

    /**
     * @return T
     */
    public function unwrapOr(mixed $or): mixed
    {
        return $this->unwrap();
    }
}

Adding the @var annotation to the promoted property, the analyser will figure out that the type of $value should be used as T.

It can then detect any problems with unwrap and unwrapOr calls.

Possible Refactoring

I've implemented everything inside of an Option interface and 2 classes that implement that interface.

This means that our Some and None classes have to implement all of the methods that are part of the contract.

It's not the prettiest thing ever, so if you wanted to refactor this you could instead create an abstract class Option that the Some and None classes extend.

You can then implement the methods inside of the abstract class instead and reduce some of the duplication between the two option types.

PHP 8.1 and beyond

PHP 8.1 will be introducing first-party enum types. This brings us one tiny step closer to a Rust-like implementation:

enum Option {
    case Some;
    case None;
}

The only differences between the two languages would be that fact that PHP can't hold values inside of enum variants and that they can't be generically typed.

I hope that PHP will get tagged enums in the near future. This draft RFC has already started the conversation.

With tagged enums, we could have something like this:

enum Option {
    case Some(private $value);
    case None;
}

Add some generics ontop and we have a slightly more verbose but more powerful Option type.

enum Option<T> {
    case Some(private T $value);
    case None;
}