Creating an `Option` Type in PHP
This post was published 3 years ago. Some of the information might be outdated!
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 withnever
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;
}