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.
- The resulting value we're initially looking for.
- 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