catpaw/unsafe

Manage errors using Unsafe<T>

1.0.4 2024-02-18 21:19 UTC

This package is auto-updated.

Last update: 2024-09-18 22:46:15 UTC


README

Install with

composer require catpaw/unsafe

Control flow is king

I am of the opinion that control flow is one of the most important things to deal with as a programmer, it affects my thinking and at times it actually guides my problem solving process.

Managing errors should not break the flow in which I control my program, I shouldn't have to jump up and down around my file to catch a new exception introduced by a new function I just invoked 20 lines above.

Try/Catch

I found myself relying way too much on code like this

try {
    // some code
} catch(SomeException1 $e){
    // manage error 1
} catch(SomeException2 $e) {
    // manage error 2
} catch(SomeException3 $e) {
    // manage error 3
}

or even

try {
    // some code
} catch(SomeException1|SomeException2|SomeException3 $e){
    // manage all errors in one place
}

The last one might make sense in theory, but in practice those exceptions might each mean something different, a different cause for an error.

The reality is that very often I lump those exceptions in together because I forget to manage them or because for some reason at 4 AM I decide on the spot "yes, I should let my IDE dictate my error management".

Try/catch error handling has been (probably) the most popular way to manage errors in php, and I think it still is a valid way of dealing with errors in a global scope.

I can't argue there is something nice about having one centralized place to manage all errors, but I don't want to be forced to approach error management all the time in that manner.

If you're anything like me you might prefer managing your error inline, directly at the source, so that you deal with it when it pops up and then you don't have to think about it anymore.

Unsafe

I have a solution.

Do not throw exceptions in your code, instead return your errors as Unsafe.

namespace CatPaw\Unsafe;
/**
 * @template T
 */
readonly class Unsafe {
    /** @var T $value */
    public $value;
    public false|Error $error;
}

Use the ok() and error() functions to create Unsafe objects.

ok()

namespace CatPaw\Unsafe;
/**
 * @template T
 * @param T $value
 * @return Unsafe<T>
 */
function ok($value);

Return ok($value) whenever there are no errors in your program.

This function will create a new Unsafe with a valid $value and no error.

error()

namespace CatPaw\Core;
/**
 * @param string|Error $error
 * @return Unsafe<void>
 */
function error($error);

Return error($error) whenever you encounter an error in your program and want to propagate it upstream.

This function will create a new Unsafe with a null $value and the given error.

Example

The following example tries to read the contents of a file while managing errors.

First I'm declaring all entities involved, classes and functions.

<?php
use CatPaw\Unsafe\Unsafe;
use function CatPaw\Unsafe\anyError;
use function CatPaw\Unsafe\error;
use function CatPaw\Unsafe\ok;

// This is not required, but you can return custom errors
class FileNotFoundError extends Error {
    public function __construct(private string $fileName) {
        parent::__construct('', 0, null);
    }

    public function __toString() {
        return "I'm looking for $this->fileName, where's the file Lebowski????";
    }
}

/**
 * Attempt to open a file.
 * @param string $fileName 
 * @return Unsafe<resource> 
 */
function openFile(string $fileName){
    if(!file_exists($fileName)){
        return error(new FileNotFoundError($fileName));
    }
    if(!$file = fopen('file.txt', 'r+')){
        return error("Something went wrong while trying to open file $fileName.");
    }
    return ok($file);
}

/**
 * Attempt to read 5 bytes from the file.
 * @param resource $stream 
 * @return Unsafe<string> 
 */
function readFile($stream){
    $content = fread($stream, 5);
    if(false === $content){
        return error("Couldn't read from stream.");
    }

    return ok($content);
}

/**
 * Attempt to close the file.
 * @param resource $stream 
 * @return Unsafe<void> 
 */
function closeFile($stream){
    if(!fclose($stream)){
        return error("Couldn't close file.");
    }
    return ok();
}

then

  1. open a file
  2. read its contents
  3. close the file
<?php
// open file
$file = openFile('file.txt')->try($error);
if ($error) {
    echo $error.PHP_EOL;
    die();
}

// read contents
$contents = readFile($file)->try($error);
if ($error) {
    echo $error.PHP_EOL;
    die();
}

// close file
closeFile($file)->try($error);
if ($error) {
    echo $error.PHP_EOL;
    die();
}

echo $contents.PHP_EOL;

This code will print the contents of file.txt if all operations succeed.

Each time ->try($error) is invoked the Unsafe object tries to unwrap its value.
If the Unsafe object contains an error, the value returned by ->try($error) resolves to null and the variable $error is assigned the contained error by reference.

anyError()

You can use anyError() to deal away with the repetitive snippet

if($error){
    echo $error.PHP_EOL;
    // manage error here...
}

Here's the same example but written using anyError()

<?php
$contents = anyError(function() {
    // open file
    $file = openFile('file.txt')->try($error)
    or yield $error;

    // read contents
    $contents = readFile($file)->try($error)
    or yield $error;


    // close file
    closeFile($file)->try($error)
    or yield $error;

    return $contents;
})->try($error);

if($error){
    echo $error.PHP_EOL;
    die();
}

echo $contents.PHP_EOL;

The anyError() function takes a generator function and it consumes it step by step.

When the generator function yields an Error or an Unsafe containing an Error, the anyError function will stop executing the generator immediately and return a new Unsafe containing the given error.

Effectively, or yield $error acts like

if($error){
    return error($error);
}

On the other hand, if the result of ->try() is valid, the or <expression> is not executed and the generator keeps running until it reaches the next yield error statement, the next return statement or until the generator is consumed.

Matching

Since errors are results, you can actually match() them

$result = anyError(/* ... */)->try($error) or match($error:class){
    FileNotFoundError::class => $error->getMessage(),
    default => "Let me explain something to you. Um, I am not Mr. Lebowski. You're Mr. Lebowski.",

};

or apply any sort of expression that you want inline.