DecodeLabs dev log

DecodeLabs dev log

Introducing: DecodeLabs Exceptional

Introducing: DecodeLabs Exceptional

A whole new take on Exceptions

Tom Wright's photo
Tom Wright
·Oct 18, 2022·

7 min read

PHP (and, as it happens, most modern languages) rely on a fairly rudimentary concept of Exceptions to handle errors at runtime. The principle is generally sound, however the implementation suffers from a handful of key flaws.

Primarily, meaning is inferred by the class name of the Exception being thrown.

throw new OutOfBoundsException('Index is not in range');

While this works, it is fundamentally limiting; PHP does not have multiple-inheritance and so can only convey one meaning directly via the class name, does not imply any context of scope (ie, where the error occurred), and requires writing unnecessary boilerplate code to represent every form of meaning being relayed.

namespace MyLibrary {
    class TooMuchTypingException extends \RuntimeException {}
}

namespace MyOtherLibrary {
    class TooMuchTypingException extends \RuntimeException {}
}

Having libraries that need to convey the same meaning but from different contexts compound this problem by either having to redefine the same class in their own namespace, or rely on traits to share functionality.

The structure of the class that makes an Exception should be dedicated to providing the functionality required to convey the state of the application in the context it is used.

Multiple meanings

While classes cannot convey multiple messages, interfaces can.

namespace MyLibrary;

interface NotFoundException {}
interface FailedServiceException {}

class MethodNotFoundException extends \RuntimeException implements NotFoundException, FailedServiceException {}

try {
    throw new MethodNotFoundException('Test');
} catch(NotFoundException | FailedServiceException $e) {}

However interfaces alone cannot immediately infer where the problem originated as you still require a class to be defined for each context from which the Exception may be thrown.

Also, this requires writing and loading lots of boilerplate code to represent what are ultimately simple, static messages.

Solution

Instead of defining a class for every Exception that may be thrown, interfaces can be generated at runtime to represent a specific meaning of an error, and assigned to anonymous classes as and when they are needed.

The generated interfaces can be placed throughout the namespace tree so that try / catch blocks can check for the same message at any level of namespace depth, and the resulting anonymous class can automatically extend from PHP's built in set of named Exceptions.

Exceptional attempts to do all of this automatically from the minimum amount of input.

namespace MyLibrary\AThingThatDoesStuff;
use DecodeLabs\Exceptional;

class Amazeballs {

    public function doStuff() {
        throw Exceptional::{'NotFound,FailedService'}(
            'Service "doStuff" cannot be found'
        );
    }
}

The resulting object would look something like this:

namespace DecodeLabs\Exceptional {
    interface Exception {}
    interface NotFoundException {
        const EXTEND = 'RuntimeException';
    }
}

namespace MyLibrary {
    interface Exception extends
        DecodeLabs\Exceptional\Exception {}
}

namespace MyLibrary\AThingThatDoesStuff {

    interface Exception extends
        MyLibrary\Exception {}

    interface NotFoundException extends
        MyLibrary\AThingThatDoesStuff\Exception,
        DecodeLabs\Exceptional\NotFoundException {}

    interface EailedServiceException extends
        MyLibrary\AThingThatDoesStuff\Exception {}

    $e = new class($message) extends \RuntimeException implements
        MyLibrary\AThingThatDoesStuff\NotFoundException,
        MyLibrary\AThingThatDoesStuff\FailedServiceException {}
}

The generated Exception can be checked for in a try / catch block with any of those scoped interfaces, root interfaces or PHP's RuntimeException.

Any functionality that the Exception then needs to convey the state of the error can then either be mixed in via traits, or by extending from an intermediate class that defines the necessary methods.

Mix and match

Exceptional exceptions can be used to greatly simplify how you generate and throw errors in your code, especially if you are writing a shared library.

Pass the name of your intended exception as a static call to the Exceptional base class and have a dynamic exception class created based on the most appropriate PHP Exception class along with a set of related interfaces for easier catching.

use DecodeLabs\Exceptional;

// Create OutOfBoundsException
throw Exceptional::OutOfBounds('This is out of bounds');


// Implement multiple interfaces
throw Exceptional::{'NotFound,BadMethodCall'}(
    "Didn't find a thing, couldn't call the other thing"
);

// You can associate a http code too..
throw Exceptional::CompletelyMadeUpMeaning('My message', [
    'code' => 1234,
    'http' => 501
]);

// Implement already existing Exception interfaces
throw Exceptional::{'InvalidArgument,Psr\\Cache\\InvalidArgumentException'}(
    'Cache items must implement Cache\\IItem',
    ['http' => 500],  // params
    $item             // data
);

// Reference interfaces using a path style
throw Exceptional::{'../OtherNamespace/OtherInterface'}('My exception');

Catch an Exceptional exception in the normal way using whichever scope you require:

namespace MyNamespace;

try {
    throw Exceptional::{'NotFound,BadMethodCall'}(
        "Didn't find a thing, couldn't call the other thing"
    );
} catch(
    \Exception |
    \BadMethodCallException |
    Exceptional\Exception |
    Exceptional\NotFoundException |
    MyNamespace\NotFoundException |
    MyNamespace\BadMethodCallException
) {
    // All these types will catch
    dd($e);
}

Traits

Custom functionality can be mixed in to the generated exception automatically by defining traits at the same namespace level as any of the interfaces being implemented.

namespace MyLibrary;

trait BadThingExceptionTrait {

    public function getCustomData(): ?string {
        return $this->params['customData'] ?? null;
    }
}

class Thing {

    public function doAThing() {
        throw Exceptional::BadThing('A bad thing happened', [
            'customData' => 'My custom info'
        ]);
    }
}

How the Exceptional interface works

The libraries main aim is to generate dynamic Exceptions based on a set of criteria in any particular context, with minimum boilerplate code.

Calling the exception generator

Exceptional combines a number of techniques to create a predictable and easy to use interface to the Exception generator mechanism.

Primarily, the main Exceptional static class provides a __callStatic() method that acts as a go-between to the Exception factory.

One major benefit of this structure is making use of the ability to pass arbitrary strings as method names to __callStatic().

Exceptional uses this feature as a means of passing through the projected type of exception to be generated, and parses that method name out to expand commas into an array:

Exceptional::{'AnythingGoesHere,BadMethodCall'}('Test exception');

// Internally
// $types = ['AnythingGoesHereException', 'BadMethodCallException'];

Calling the factory

It is the sole responsibility of the Factory to actually generate an instance of an Exception for the calling code to throw.

It uses a combination of eval() and anonymous classes to build a custom class specific to the current context containing a mix of interfaces and traits, to define type, message and functionality.

Stack frame

The exception Factory uses debug_backtrace() to work out the namespace from which Exceptional was called and uses this to decide which interfaces need to be generated and what needs to be rolled into the final Exception class.

It's aim is to have an interface named with each of the types defined in the original call to the Factory (eg Runtime, NotFound) defined within the namespace of the originating call so that catch blocks can reference the type directly.

namespace Any\Old\Namespace;
use DecodeLabs\Exceptional;

try {
    throw Exceptional::Runtime('message');
} catch(
    \RuntimeException |
    RuntimeException |
    Any\Old\Namespace\RuntimeException $e
) {
    // do something
}

Secondary to that, if the requested types are listed as primary exception types by the Factory then there will also be an interface to represent it in the Exceptional namespace:

namespace Any\Old\Namespace;
use DecodeLabs\Exceptional;

try {
    throw Exceptional::Runtime('message');
} catch(Exceptional\RuntimeException $e) {
    // do something
}

On top of that, the Factory will ensure there is an interface named Exception at every namespace level up the tree to the target namespace (so long as that name is free in that context) so that developers can choose the granularity of catch blocks, ad hoc:

namespace Any\Old\Namespace;

use MyLibrary\InnerNamespace\SomeClass;

$myLibrary = new SomeClass();

try {
    // This method will throw an Exceptional Exception
    $myLibrary->doAThing();
} catch(
    MyLibrary\InnerNamespace\Exception $e |
    MyLibrary\Exception $e |
    Exceptional\Exception $e
) {
    // All of the above tests will match
}

To increase compatibility with SPL exceptions, any types that have a corresponding SPL Exception class will extend from that type, rather than the root Exception class:

namespace Any\Old\Namespace;
use DecodeLabs\Exceptional;

try {
    throw Exceptional::Runtime('message');
} catch(\RuntimeException $e) {
    // do something
}

And then for any interface that is added to the final type definition, the equivalent \Trait trait will be added too, if it exists. This allows the inclusion of context specific functionality within a specific category of Exceptions without having to tie the functionality to a particular meaning.

As an example, given the fallowing Exceptional call:

namespace MyVendor\MyLibrary\SubFunctions;
use DecodeLabs\Exceptional;

trait RuntimeExceptionTrait {

    public function extraFunction() {
        return 'hello world';
    }
}

try {
    throw Exceptional::Runtime('message');
} catch(RuntimeException $e) {
    echo $e->extraFunction();
}

The resulting anonymous class will include:

  • MyVendor\MyLibrary\SubFunctions\RuntimeException interface
  • MyVendor\MyLibrary\SubFunctions\RuntimeExceptionTrait trait, with extraFunction()
  • DecodeLabs\Exceptional\RuntimeException interface
  • MyVendor\MyLibrary\SubFunctions\Exception interface
  • MyVendor\MyLibrary\Exception interface
  • MyVendor\Exception interface
  • RuntimeException base class

Repeated execution

Once the Factory has generated an Exception for a particular subgroup of requested types within a specific namespace, it is hashed and cached so that repeated calls to the Factory within the same context can just return a new instance of the anonymous class. The resulting performance overhead of general usage of Exception exceptions then tends to be trivial, while the development overhead is massively reduced as there is no need to define individual Exception classes for every type of error in all of your libraries.

Roadmap

At the time of writing, Exceptional is at pre-release version v0.4.3 - it is essentially feature complete and just requires test coverage and final documentation before a full v1 public release.

Think you might have a way to improve on Exceptional? Let us know in the comments below!

 
Share this