- Status: accepted
- Deciders: Thomas GERBET, Joris MASSON, Nicolas TERRAY, Manuel VACELET
- Date: 2023-03-07
Technical Story: request #31117 Introduce a generic Option<T>
type
The semantics of null
are muddy. Historically, we have used it to represent the absence of a value. SomeType|null
(shortened to ?SomeType
in PHP) often means "SomeType or nothing". However, we have also used it to indicate technical or business problems, in which case the null
masks several possibilities. It can also sometimes be a valid value.
Returning SomeType|null
means the caller must each time perform a null-check when they want to handle the value (or its absence). It leads to a proliferation of null-checks everywhere, as sometimes a null
leads to other null
s up the stack, for example with objects that depend on another object to be built. A quick search for null-check patterns in our PHP codebase yields thousands of results. The same issue exists in TypeScript, but made worse by the addition of undefined
to the mix.
Can we find a better way to represent the absence of a value and avoid the proliferation of null-checks ?
Inspired by functional programming, we introduce PHP and TypeScript implementations of the Maybe
/Option
type present in functional programming languages. See Haskell's Maybe or Rust's Option for examples.
Option
is either a Some
variant (holding a value) or a None
variant (holding nothing). Both implement the same methods, but each variant skips methods that are not meant for it. For example, both Some
and None
implement apply()
but calling apply()
on a None
variant will do nothing. A method meant to work on a Some
variant will be skipped (it will do nothing) on a None
and vice-versa.
Leveraging this, we can stop writing null-checks every time we might get no value.
// Instead of writing this:
interface Retriever
{
public function itCouldReturnNothing(): SomeValue | null;
}
class Upper
{
private Retriever $retriever;
public function upper(): void
{
$value = $this->retriever->itCouldReturnNothing();
if ($value !== null) {
// Do something with $value
}
}
}
// We can write this:
interface Retriever
{
/**
* @return Option<SomeValue>
*/
public function itCouldReturnNothing(): Option;
}
class Upper
{
private Retriever $retriever;
public function upper(): void
{
$this->retriever->itCouldReturnNothing()->apply(function (SomeValue $value) {
// Do something with $value
// This will not be executed if Retriever returns a None variant
});
}
}
- Functions that can return a value or nothing should return
Option
.
- The possible absence of value is better communicated by the return type.
- It avoids using
null
(orundefined
in TypeScript) to indicate a value might not be present.null
can be a valid value or have a different meaning than "missing" (for example, it can be a "to be removed") - It does not force every caller to check for
null
: using built-in functions, calling code can add behaviour that will be skipped when the Option is aNone
variant. - It reduces conditional logic and thus reduces testing efforts.
- Since PHP does not natively support generics, we must add doc-blocks to have more precise return types:
@returns Option<TypeOfValue>
. - It introduces a new way of representing the possible absence of value that will coexist with existing ways (
null
,undefined
) for a very long time, leading to reduced consistency.
- Pull
Option
from external libraries - Write an implementation ourselves
Chosen option: "Write an implementation ourselves" because it comes out best in the comparison (see below).
We could pull existing implementations from existing libraries, such as azjezz/psl for PHP or fp-ts for TypeScript.
- Good, because it's less initial work as we don't have to write code.
- Bad, because it's very difficult to find libraries with similar API across PHP and TypeScript. It would lead to reduced consistency. For example, the API of
fp-ts
is all about importing each function you need, while our PHP implementation relies on a single class. Other options like ts-opt or option-t have very different (and incompatible) APIs, with the former using a class-like, chainable method pattern and the latter exporting individual functions. - Bad, because it comes with the risk of future breaking changes that we will be forced to adapt to.
- Bad, because relying a lot on big libraries such as
azjezz/psl
orfp-ts
comes with a risk of "overusing" them. It echoes our history with lodash. It was a big library, it simplified very common patterns (such as mapping arrays), until the most common operations were included in the standard library of ES2015. After that point, the value brought by lodash had become much lower, it was now too heavy for its value. Thus, we started removing usages of lodash, but there were so many it became a very long work. For such a common pattern as "handling the absence of value", it seems risky to base so much code on a big external library. - Bad, because creating a
None
variant withazjezz/psl
givesOption<never>
, which makes us "force" the proper type with a@var
annotation. - Bad, because we had already implemented
Option
in PHP before starting to useazjezz/psl
. We would need to adjust that code to the new API.
We could write implementations ourselves, as we have already done for Result
.
- Good, because we can make the APIs consistent across PHP and TypeScript (save for the differences inherent to the languages).
- Good, because it's not very complicated code. The main challenge lies in the type system.
- Good, because if we need to do breaking changes, we can adjust all dependent code in the codebase at the same time.
- Bad, because it's more initial work.
- Java developers: our
Option<T>
is different to yourOptional<T>
since PHP and TypeScript have a decent handling ofnull
values.Optional<T>
meansT
ornull
.Option<T>
meansT
or the absence of value.