Skip to content

Usage: Error enums

Adrian edited this page May 12, 2024 · 13 revisions

Error enums provide all the "core" features for exceptables. This is where you have error cases for specific problems your application can encounter, error codes, messages and message templates, and tools for building Exceptable instances.

The IsError trait provides a complete base implementation for the Error interface. All you need to do is make an enum that contains your error cases, implement the interface and use the trait.

getting started

defining error cases

It might seem counter-intuitive, by my recommendation is to actually start with a completely empty Error enum - don't define any error cases yet.

<?php
namespace Foo;

use at\exceptable\ {
  Error,
  IsError
};

enum FooError : int implements Error {
  use IsError;
}

...then, just start writing your code. Why? Because this way, you can easily discover what error cases you actually have and need to cover. Your actual error cases should dictate what error cases you define and use, rather than defining them upfront and then trying to fit every error case to one of your imagined cases.

Say we're writing some code that gives customized welcome messages for different people.

<?php
namespace Foo;

class Greeter {

  private array $welcomes = [ . . . ];

  public function welcome(string $name) : string {
    return strtr($this->getCustomWelcomeFor($name), ["{name}" => $name]);
  }

  private function getCustomWelcomeFor(string $name) : string {
    return $this->welcomes[$name];
  }
}

...and now, we see our first error condition: what if there's no welcome message for the given $name? As soon as we find this problem, we can go back to our Error enum and write a case for it. We can also make a human-readable error message for it, and include relevant context that can help us track down what might have caused the problem.

enum FooError : int implements Error {

  . . .

  case UnknownFoo = 1;

  public const MESSAGES = [
    self::UnknownFoo->name => "i don't know who, you think is foo, but it's not {foo}"
  ];
}

And then, resume writing the happy path of your code, with the error condition properly handled:

namespace Foo;
use FooError;

class Greeter {

  . . .

  private function getCustomWelcomeFor(string $name) : string {
    if (empty($this->welcomes[$name])) {
      throw (FooError::UnknownFoo)(["foo" => $name]);
    }

    return $this->welcomes[$name];
  }
}

how things work

we have your back

Should you make Error enums with plain enums, or backed enums? I prefer and recommend backed enums, but this is not necessary unless you want to define specific error codes for each error case. If you use a backed enum, it must be integer-backed.

For example, if you're making an Error enum for http-related errors, you'd probably want to use a backed enum - but in other cases, where it doesn't strictly matter what the error's code is (only that it has one), you can skip it. When you use a plain enum, error cases are simply numbered, starting with 1.

use at\exceptable\ {
  Error,
  IsError
};

enum HttpError : int implements Error {
  use IsError;

  case BadRequest = 400;
  case InternalServerError = 500;
}

enum SomethingHappenedError implements Error {
  use IsError;

  case TheFirstThing;
  case SecondThing;
}
echo HttpError::BadRequest->code();
// prints "400"

echo SomethingHappenedError::TheFirstThing->code();
// prints "1"

just toss it out there

You probably noticed that we used throw in our code, but ...enums aren't throwable? The Error interface requires the __invoke() method, which means you can invoke an Error enum case like you would a regular function, and you get a new Exceptable from it - which is Throwable.

This is purely convenience, but is useful. You can think of it as a factory method.

<?php

use FooError;
use at\exceptable\Spl\RuntimeException;

$e1 = (FooError::UnknownFoo)();

// exactly the same as
$e2 = FooError::UnknownFoo->newExceptable();

// exactly the same as
$e3 = new RuntimeException(FooError::UnknownFoo);

Since all of these approaches produce the same result, you can use whichever is most convenient and understandable to you.

So, how did we end up with an Spl\RuntimeException instance? That's the type that the IsError trait uses, so that's what you'll get by default. You can control this using the exceptableType() method if you want a different type.

use at\exceptable\ {
  Error,
  Exceptable,
  IsError,
  IsExceptable,
  Spl\OverflowException,
  Spl\UnderflowException
};

enum SomethingHappenedError implements Error {
  use IsError;

  case TheFirstThing;
  case SecondThing;

  public function exceptableType() : string {
    return match ($this) {
      self::TheFirstThing => OverflowException::class,
      self::SecondThing => UnderflowException::class
    };
  }
}

try {
  throw rand(0, 1) ? 
    (SomethingHappenedError::TheFirstThing)() :
    (SomethingHappenedError::SecondThing)();
} catch (OverflowExcxeption $x) {
  // it was the first thing
} catch (UnderflowException $x) {
  // it was the second thing
}

ICU messaging

There are two ways you can define error messages for your Error enums. The first is as shown above, by adding messages to the MESSAGES array, indexed by Error case name.

The second is by building ICU Resource Bundles. How to do so is beyond the scope of this wiki, but use of genrb is recommended. When using Resource Bundles, Error enums assume the top-level key will be the Error classname (with backslashes (\) replaced with underscores (_)) - this is done to help prevent key collisions. Thus, an example txt resource (before compiling) might look like the following:

{
  Foo_FooError {
    UnknownFoo { "i don't know who, you think is foo, but it's not {foo}" }
    SpookyFoo { "ph'nglui mglw'nafh {foo} r'lyeh wgah'nagl fhtagn" }
  }
}

Alternatively, if you want to use your own message structure, you can override the messageKey() method to return any key you want. For example,

use at\exceptable\ {
  Error,
  IsError
};

enum SomethingHappenedError implements Error {
  use IsError;

  case TheFirstThing;
  case SecondThing;

  protected function messageKey() : string {
    return match ($this) {
      self::TheFirstThing => "my-messages.errors.something-happened.the-first-thing",
      self::SecondThing => "my-messages.errors.something-happened.second-thing"
    };
  }

  . . .
}

...which would correspond to a message bundle like:

{
  my-messages {
    errors {
      something-happened {
        the-first-thing { "it was the first thing" }
        second-thing { "it was the second thing" }
      }
    }
  }
}

ICU Feature Support

If you have the intl extension installed, both resource bundles and MESSAGES formatting strings can use any and all ICU message formatting and localization features. If not, then only MESSAGES will be used, and only basic token interpolation is supported. Because of this, it's recommended that you avoid using complex ICU formats in the MESSAGES array, as support for them can't be guaranteed to be available.