PHP 8.0 union types and what they have to do with exceptions handling

16 August 2023

Exceptions in PHP

Currently php has a decent, yet unclear exception system.
There is no explicit segregation between checked and unchecked exceptions as it is in Java. Therefore, PHP ecosystem by default has this issue when every exceptional case is considered runtime.

I intentionally avoid explaining what are the checked and unchecked exceptions in this post. Please, do your own research.

One can meet a projects with oversaturated docblocks

/**
 * @throws NonExistentProduct
 * @throws UnexpectedValueException
 * @throws DatabaseConnectionException
 * @throws ORMException
 * @throws RuntimeException
 */
function buyProduct(int $productId): Product {
    //
}

or

function buyProduct(int $productId): Product {
    throw new RuntimeException('Will know about this when you run your code');
}

Both of these cases are about incorrect exception usage but to find a "correct" way one has to perform a lot of research. By "a lot" I mean really a lot, because it is difficult to research the topic you're unfamiliar with and have no right direction to go in the first place.

Thereby, we have code that has a lot of silent failures or way too many failures that no one will ever handle. It is not how it may go in our projects - it is most likely how it already is. Leave a comment if it hit the spot or not.

Not every error is an exception

Here we come to the sweet part. There are a lot of cases when you call a function and it ends up with error. The error you completely expected for some cases.
Take a look at the following example.

function buyProduct(int $productId): void {
    if (!productInStock($productId)) {
        throw new OutOfStockException('Product is out of stock');
    }
    // buy
}


try {
    buyProduct($productId);
} catch (OutOfStockException $e) {
    echo 'Product went out of stock. Sorry.';
}

It represents the situation when one expects the clause.
Unfortunately, exceptions were never designed for a normal workflow. It means, that you shall not write your code logic based on exceptions. Domain logic based on exceptions is an absolute no-no while UI behaviour based on them is a bad pattern yet tolerable.
It would nicely wrap up as "Modern languages shouldn't have hidden control flows".
Exceptions are slow and half of their initial interface is designed as a debug tool. It helps developer to quickly gather the traces of an issue.

I'm not educated enough to say that for sure but all of the above is likely the primary reason why modern languages avoid using exceptions.

For example, rust has forced handling like as follows.


fn main() {
    let result = buy_product(productId);

    let result = match result {
        Ok(product) => println("Bought a product"),
        Err(error) => println("Handling the error occurred"),
    };
}

If you skip the handling, the program compile at all.

Golang has


product, err := buyProduct(productId)
if err != nil {
    fmt.println("Handling the error")
}

fmt.println("Bought a product")

What unites them is the fact that both languages return two values.

  1. The resulting value we're initially looking for.
  2. The error that is declared if one is returned along the result.

In PHP it would like as follows


class Error {public string $msg;}

function buyProduct(int $productId): array {
    if (!productInStock($productId)) {
        return [null, new Error('Product is out of stock')];
    }

    return [new Product(...), null];
}


[$product, $error] = buyProduct($productId);
if ($error) {
    echo $error->msg;
}

As you could notice, this one doesn't look particularly good since we don't have type checks, type hints and we can't even force them.

Another approach was generic return types which basically wraps the result.

Like


/**
 * @return Result<Product>
 */
function buyProduct(int $productId): array {
    if (!productInStock($productId)) {
        return Result::error('division by zero can not be performed');
    }

    return Result::success(new Product(...));
}

$result = buyProduct($productId);

if (!$result->isSuccessful()) {
    echo $result->getError()->getMessage();
}

It was a pretty solid solution with a minor drawback (you can get details out from the link).

Union types

Union type feature extends type highlight by allowing us to declare multiple types for a single value.

Like this

function roundNumber(float $number): float|int
{
    //
}

Though, initial reason behind RFC was about lots of legacy functions with ambiguous input/output types, we've got something bigger in result.
I reckon, this might have huge impact on the code quality and here is why.


class Error {public string $msg;}

function buyProduct(int $productId): Product|Error {
    if (!productInStock($productId)) {
        $error = new Error();
        $error->msg = 'Product is out of stock';

        return $error;
    }

    return new Product(...);
}

$result = buyProduct($productId);
if ($result instanceof Error) {
    echo $result->msg;
}



First, we resolved the types. Whatever you do, your result is either normal-flow type or error-flow type.
With

declare(strict_types=1);

we are fully settled.
Beyond that, it resolves exception-ignoring issue. Now that our intentions are explicit, static code analyzer can surely tell when we are passing wrong data types to wrong places.
For example, the following will be much harder to ignore


function buyProduct(int $productId): Product|Error {...}


function shipPurchase(Product $purchasedProduct): void {}


$product = buyProduct($productId);


shipPurchase($product);

Static analyzer such as PHPStan reports type mismatch here since attempt to ship the $product without checking if $product is not an Error, means that datatype Product|Error is not compatible with Product.

I hope, PHP will start doing that itself someday too.
Even if it doesn't, it is still much better since this way you can get rid of error bubbling too.

Error bubbling means exception goes through layers unhandled and ends up with situations where calling buyProduct might throw something like HttpProtocolException

Don't take everything plain: we have to challenge and prove the information we face.

Here is what really helps me to do it.