enums
The Missing Data Type
Andy Snell | @andrewsnell
What Does It Mean To Be Enumerable?
Things that Exist in Known, Limited, and Discrete States
@andrewsnell
What Does It Mean To Be Enumerable?
Enum values are important to us because of what they represent and in context to other values in the set
UserRole |
---|
admin |
manager |
user |
guest |
InvoiceType |
---|
standard |
prorated |
comped |
CardBrand |
---|
visa |
mastercard |
discover |
amex |
@andrewsnell
ProcessING a Card Payment
A payment result will have only one of three values:
APPROVED
DECLINED
ERROR
@andrewsnell
public function processPayment()
{
$result = $this->payment_gateway->run();
if ($result) {
if ($result->approval_code) {
return 'approved';
}
return 'declined';
}
return 'error';
}
PaymentProcessor
PROCESSING A CARD PAYMENT
"Magic Strings"
@andrewsnell
public function setResult($result)
{
$result = strtolower($result);
if (!in_array($result, [
'approved',
'declined',
'error',
])) {
throw new UnexpectedValueException('Not Valid!');
}
$this->result = $result;
}
PaymentModel
PROCESSING A CARD PAYMENT
"Magic Strings"
@andrewsnell
public function processPayment()
{
$result = $this->gateway->run();
if ($result) {
if ($result->approval_code) {
return 1;
}
return 2;
}
return 0;
}
PaymentProcessor
PROCESSING A CARD PAYMENT
"Magic Integers"
@andrewsnell
class PaymentResult
{
public const APPROVED = 'approved';
public const DECLINED = 'declined';
public const ERROR = 'error';
}
PaymentResult
PROCESSING A CARD PAYMENT
Class Constants
@andrewsnell
public function processPayment()
{
$result = $this->gateway->run();
if ($result) {
if ($result->approval_code) {
return PaymentResult::APPROVED;
}
return PaymentResult::DECLINED;
}
return PaymentResult::ERROR;
}
PaymentProcessor
PROCESSING A CARD PAYMENT
Class Constants
@andrewsnell
public function setResult($result)
{
$result = strtolower($result);
if (!in_array($result, [
PaymentResult::APPROVED,
PaymentResult::DECLINED,
PaymentResult::ERROR,
])) {
throw new UnexpectedValueException('Not Valid!');
}
$this->result = $result;
}
PaymentModel
PROCESSING A CARD PAYMENT
Class Constants
@andrewsnell
public function setResult(string $result): void
{
$result = strtolower($result);
if (! PaymentResult::isValid($result)) {
throw new UnexpectedValueException('Not Valid!');
}
$this->result = $result;
}
PaymentModel
PROCESSING A CARD PAYMENT
>= PHP 7.0: Type Declarations
@andrewsnell
class PaymentResult
{
public const APPROVED = 'approved';
public const DECLINED = 'declined';
public const ERROR = 'error';
public static function isValid(string $value): bool
{
$class = get_called_class();
$reflect = new ReflectionClass($class);
$constants = $reflect->getConstants();
return in_array($value, $constants, true);
}
}
PaymentResult
PROCESSING A CARD PAYMENT
>= PHP 7.0: Type Declarations
@andrewsnell
So, What's The Problem ?
Class Constants help with code comprehension and centralization.
They are just syntactic sugar around scalars.
@andrewsnell
So, What's The Problem ?
class AccountStatus
{
public const APPROVED = 'approved';
public const ACTIVE = 'active';
public const DEACTIVATED = 'deactivated';
}
AccountStatus
$result = $this->gateway->run($transaction);
if($result->status === 201){
$transaction->setResult(AccountStatus::APPROVED)
}
This works, but it is oh, so wrong...
@andrewsnell
So, What's The Problem ?
Scalar Type Definitions reduce type errors.
They do not improve readability.
Working directly with scalars allows illogical operations.
@andrewsnell
class WeekDay
{
public const MONDAY = 0;
public const TUESDAY = 1;
public const WEDNESDAY = 2;
public const THURSDAY = 3;
public const FRIDAY = 4;
public const SATURDAY = 5;
public const SUNDAY = 6;
}
WeekDay::SATURDAY + WeekDay::SUNDAY === 11;
So, What's The Problem ?
This is perfectly legal...
@andrewsnell
So, What's The Problem ?
Validation reduces runtime errors.
@andrewsnell
Only applies to the current context.
Cannot trust a scalar value to not change.
So, What's The Problem ?
function tomorrow(string &$weekday): int
{
if (WeekDay::isValid($weekday)) {
$tomorrow = ++$weekday;
if($weekday > 6){
$tomorrow = 0;
}
return $tomorrow;
}
throw new \InvalidArgumentException();
}
$today = WeekDay::SUNDAY; // (int)6
$tomorrow = tomorrow($today); // (int) 0
var_dump($today); // (int)7
A bit contrived, but we've all seen code like this...
@andrewsnell
We don't want to pass around a SCALAR Value.
We Want Something That IS A "Payment Status"
The IdeAL Solution:
A Native Data Type
In Summary
@andrewsnell
What is an Enumerated Type?
A type is an attribute of a unit of data that tells the computer what values it can take and what operations can be performed upon it.
PHP Types |
---|
boolean |
integer |
float |
string |
array |
object |
callable |
iterable |
resource |
NULL |
What is a Type?
@andrewsnell
What is an Enumerated Type?
Enumerated Type Defined
An enumerated type is a named, language-level, wrapper that restricts the possible values of an instance to a discrete set of named members, called enumerators, at compile time.
In practice, this means that an enum variable can have any value in the set and only values in the set can be values.
@andrewsnell
What is an Enumerated Type?
PHP Does Not Have an Enumerated Data Type
@andrewsnell
*
What is an Enumerated Type?
The Enumerated Type PHP Does Have...
enum boolean {
false = 0,
true = 1
};
@andrewsnell
What is an Enumerated Type?
What would it look like if we did have one?
@andrewsnell
Look To Other Languages!
ENUMS IN Other Languages
#include <stdio.h>
enum payment_result {
APPROVED, // 0
DECLINED, // 1
ERROR // 2
};
int main()
{
enum payment_result result;
result = ERROR;
printf("Result: %d", result); // "Result 2"
return 0;
}
C
@andrewsnell
ENUMS IN Other Languages
<?hh
enum PaymentResult: string {
APPROVED = 'approved';
DECLINED = 'declined';
ERROR = 'error';
};
echo PaymentResult::APPROVED . PHP_EOL; // "approved\n"
Hack
@andrewsnell
ENUMS IN Other Languages
Python
from enum import Enum
class PaymentStatus(Enum):
APPROVED = "approved"
DECLINED = "declined"
ERROR = "error"
def foo(self):
return "Hello World"
@property
def is_failed(self):
return self != self.APPROVED
print(PaymentStatus.APPROVED.value) # "approved"
print(PaymentStatus.APPROVED.is_failed) # "False"
print(PaymentStatus.DECLINED.is_failed) # "True"
print(PaymentStatus.APPROVED.foo()) # "Hello, World"
@andrewsnell
class Main {
public enum PaymentResult {
APPROVED ("approved"),
DECLINED ("declined"),
ERROR ("error");
private final String value;
PaymentResult(String value) {
this.value = value;
}
public final String value(){
return this.value;
}
}
public static void main(String[] args) {
PaymentResult result = PaymentResult.DECLINED;
System.out.println(result.value()); // "declined"
}
}
Java
ENUMS IN Other Languages
@andrewsnell
Why Doesn't PHP Have Enums?
@andrewsnell
We were not ready for it!
@andrewsnell
So, Why No Enum Type?
PHP is a developed language, not designed!
@andrewsnell
We've Tried Before, but...
So, Why No Enum Type?
@andrewsnell
Enumerated Types Need Strongly Typed Contexts
Picking Implementation Details is Hard
So, Why No Enum Type?
@andrewsnell
SplEnum
So, Why No Enum Type?
@andrewsnell
Enumerated Object
function setResult(PaymentResult $result): PaymentResult
{
return $result;
}
$result = PaymentResult::APPROVED();
setResult($result)
What Can We Do Now?
@andrewsnell
What Should An Enumerated Object Look Like?
@andrewsnell
ENUM OBJECT Properties
Immutable
Self-Valid
Comparable
Castable
@andrewsnell
ENUM OBJECT Properties
Immutable
@andrewsnell
Self-Valid
ENUM OBJECT Properties
@andrewsnell
Comparable
PaymentResult::APPROVED() == PaymentResult::APPROVED();
'approved' === PaymentResult::APPROVED()->value();
ENUM OBJECT Properties
@andrewsnell
Castable
'approved' === (string) PaymentResult::APPROVED();
PaymentResult::make('approved') == PaymentResult::APPROVED();
ENUM OBJECT Properties
@andrewsnell
This Sounds Familiar...
Value Object
"A small simple object, like money or a date range, whose equality isn't based on identity."
"...I follow a simple but important rule: value objects should be immutable"
-Martin Fowler
@andrewsnell
Building Our Enum
@andrewsnell
<?php
declare(strict_types=1);
namespace App;
/**
* @method static PaymentResult APPROVED()
* @method static PaymentResult DECLINED()
* @method static PaymentResult ERROR()
*/
final class PaymentResult extends Enum
{
protected static $values = [
'APPROVED' => 'approved',
'DECLINED' => 'declined',
'ERROR' => 'error',
];
}
Defining the Child Class...
@andrewsnell
abstract class Enum
{
protected $value; // The underlying scalar value
public static function __callStatic($enum, $args): self
{
return new static($enum);
}
protected function __construct(string $enum)
{
$enum = strtoupper($enum);
if (!array_key_exists($enum, static::$values)) {
throw new UnexpectedValueException();
}
$this->value = static::$values[$enum];
}
public function __set($name, $value): void
{
throw new \LogicException("Read Only");
}
Define a Self-Valid, Immutable Parent Class...
@andrewsnell
<?php
$result = PaymentResult::UNDEFINED(); // Throws Exception
$result = PaymentResult::APPROVED(); // PaymentResult
$result = PaymentResult::DECLINED(); // PaymentResult
$result = PaymentResult::ERROR(); // PaymentResult
$result->foo = "hello, world"; // Throws Exception
Using the Enum: Instantiation
@andrewsnell
// Make an instance from an underlying scalar value
public static function make($value): self
{
$enum = array_search($value, static::$values, true);
if ($enum === false) {
throw new UnexpectedValueException();
}
return new static($enum);
}
// Return the underlying scalar value
public function value()
{
return $this->value;
}
// Allow an instance to be cast to a string of the value
public function __toString(): string
{
return (string)$this->value;
}
Let's Make It Castable...
@andrewsnell
<?php
$result = PaymentResult::make('approved'); // Payment Result
'approved' === (string) $result; // true
'approved' === $result->value(); // true
Using the Enum: Castable
@andrewsnell
abstract class Enum
{
// ...
public function is($value): bool
{
if ($value instanceof static) {
return $this->value === $value->value;
}
return $this->value === $value;
}
}
Let's Make It Comparable...
@andrewsnell
// COMPARABLE
PaymentResult::APPROVED() == PaymentResult::APPROVED(); // true;
PaymentResult::APPROVED() === PaymentResult::APPROVED(); // false;
PaymentResult::APPROVED() == PaymentResult::DECLINED(); // false
$result = PaymentResult::APPROVED(); // PaymentResult
$result->is('approved'); // true
$result->is('declined'); // false;
$result->is(PaymentResult::APPROVED()); // true
$result->is(PaymentResult::DECLINED()); // false
Using the Enum: Comparison
@andrewsnell
public function processPayment(): PaymentResult
{
$result = $this->gateway->run();
if ($result) {
if ($result->approval_code) {
return PaymentResult::APPROVED();
}
return PaymentResult::DECLINED();
}
return PaymentResult::ERROR();
}
Using the Enum: Updating Our Code
public function setResult(PaymentResult $result): void
{
$this->result = $result->value();
}
@andrewsnell
<?php
declare(strict_types=1);
namespace App;
/**
* @method static PaymentResult APPROVED()
* @method static PaymentResult DECLINED()
* @method static PaymentResult ERROR()
*/
final class PaymentResult extends Enum
{
protected static $values = [
'APPROVED' => 'approved',
'DECLINED' => 'declined',
'ERROR' => 'error',
];
protected static $cache = [];
}
What about caching the object?
@andrewsnell
abstract class Enum
{
protected $value;
public static function __callStatic($enum, $args): self
{
return static::$cache[$enum] ??= new static($enum);
}
public static function make($value): self
{
$enum = array_search($value, static::$values, true);
if ($enum === false) {
throw new UnexpectedValueException();
}
return static::$cache[$enum] ??= new static($enum);
}
// ...
What about caching the object?
@andrewsnell
Caching !== Comparison by Identity
@andrewsnell
$result = PaymentResult::APPROVED();
$serialized = serialize($result);
var_dump($result, unserialize($serialized));
object(App\Cached\PaymentResult)#388 (1) {
["value":protected]=>
string(8) "approved"
}
object(App\Cached\PaymentResult)#360 (1) {
["value":protected]=>
string(8) "approved"
}
Don't Reinvent the Wheel
spatie/enum
myclabs/php-enum
BenSampo/laravel-enum
@andrewsnell
/**
* @method static self approved()
* @method static self declined()
* @method static self error()
*/
class PaymentResult extends Enum
{
}
$result = PaymentResult::approved();
$result = PaymentResult::make('approved');
$result->getValue(); // 'approved';
$result->isApproved(); // true;
$result->isEqual(PaymentResult::approved()); // true
$result->isEqual('approved'); // true
$result->isEqual(PaymentResult::declined()); // false
@andrewsnell
spatie/enum Package
<?php
declare(strict_types=1);
namespace App\Services\Reporting\KpiReporting;
use MyCLabs\Enum\Enum;
/**
* @method static KpiReportPeriod CURRENT()
* @method static KpiReportPeriod DAILY()
* @method static KpiReportPeriod MONTH_TO_DATE()
* @method static KpiReportPeriod QUARTER_TO_DATE()
* @method static KpiReportPeriod YEAR_TO_DATE()
* @method static KpiReportPeriod CUSTOM()
*/
class KpiReportPeriod extends Enum
{
private const CURRENT = 'current';
private const DAILY = 'daily';
private const MONTH_TO_DATE = 'month_to_date';
private const QUARTER_TO_DATE = 'quarter_to_date';
private const YEAR_TO_DATE = 'year_to_date';
private const CUSTOM = 'custom';
}
@andrewsnell
MyCLabs\Enum Package
final class UserPermissions extends FlaggedEnum
{
private const READ_COMMENTS = 1 << 0; // 1
private const WRITE_COMMENTS = 1 << 1; // 2
private const EDIT_COMMENTS = 1 << 2; // 4
private const DELETE_COMMENTS = 1 << 3; // 8
// Shortcuts
public const Member = self::READ_COMMENTS | self::WriteComments;
public const Moderator = self::Member | self::EditComments;
public const Admin = self::Moderator | self::DeleteComments;
}
$permissions = new UserPermissions([
UserPermissions::READ_COMMENTS(),
UserPermissions::WRITE_COMMENTS(),
UserPermissions::EDIT_COMMENTS(),
]);
$permissions->hasFlag(UserPermissions::READ_COMMENTS()); // true
$permissions->hasFlag(UserPermissions::DELETE_COMMENTS()); // false
@andrewsnell
BenSampo/laravel-enum Package
What Else Can We Do?
Finite State Machine
Oversimplified!
@andrewsnell
Finite State Machine
- Is a mathematical model of computation.
- Can exist in one of a finite number of states at a time.
- Can transition from one state to another according to rules.
- Defined by a list of states, the initial state, and transitions.
@andrewsnell
Finite State Machine
AN ENUM + TRANSITION RULES
= A SIMPLE FINITE STATE MACHINE
@andrewsnell
final class AccountStatus extends FiniteStateMachineEnum
{
protected static $cache = [];
protected static $values = [
'ACCEPTED' => 'accepted',
'APPROVED' => 'approved',
'DECLINED' => 'declined',
'ACTIVATING' => 'activating',
'ACTIVATING_FAILED' => 'activating_failed',
'ACTIVE' => 'active',
'DEACTIVATING' => 'deactivating',
'DEACTIVATED' => 'deactivated',
];
protected static $transitions = [
'accepted' => ['APPROVED', 'DECLINED'],
'approved' => ['ACTIVATING'],
'declined' => [],
'activating' => ['ACTIVATING_FAILED', 'ACTIVE'],
'activating_failed' => ['ACTIVATING', 'DECLINED'],
'active' => ['DEACTIVATING'],
'deactivating' => ['ACTIVE', 'DEACTIVATED'],
'deactivated' => ['ACTIVATING'],
];
OUR OversimplifieD Machine
@andrewsnell
abstract class FiniteStateMachineEnum extends Enum
{
public function canTransitionTo(self $enum): bool
{
return in_array($enum->value, static::$transitions[$this->value], true);
}
public function transition(self $enum): self
{
if (!$enum instanceof static) {
throw new RuntimeException('Invalid Enum');
}
if (!$this->canTransitionTo($enum)) {
throw new RuntimeException('Invalid Transition');
}
return $enum;
}
OUR OversimplifieD Machine
@andrewsnell
$status = AccountStatus::ACTIVATING(); // AccountStatus
$status->canTransitionTo(AccountStatus::DECLINED()); // false
$status->canTransitionTo(AccountStatus::ACTIVE()); // true
$status->transition(AccountStatus::DECLINED()); // throws exception
$new_status = $status->transition(AccountStatus::ACTIVE()); // AccountStatus
$status == AccountStatus::ACTIVATING(); // true (immutable)
$new_status == AccountStatus::ACTIVE(); // true
OUR OversimplifieD Machine
@andrewsnell
So, What's Next?
If and when will we get an enum type in PHP?
- Language Has Grown, Seems Ready Soon
- Upcoming RFCs that Affect Types - Union Types in 8.0
- Some Definite Advantages over Enumerated Objects
- Still, May End Up Using Enumerated Objects Anyway
@andrewsnell
Thank you!
Andy SnelL
@andrewsnell
Slides & Resources
https://bit.ly/30StLbh
Enums: The Missing Data Type
By Andy Snell
Enums: The Missing Data Type
PHP may not have a native data type for an enumerated type (“enum”), like other programming languages, but there are userland solutions we can leverage to get access to this powerful data type. We’ll see how representing things like statuses with enums provides immutability, improved readability, and type safety — preventing the kind of errors that happen with “magic strings” and class constants. In this session, we’ll be making our own immutable enums from scratch in order to explore the concept, but we’ll also introduce two libraries for use in your production code. We’ll also demystify the imposing-sounding “finite state machine” by using using immutable enum objects to regulate the transitions between statuses. Resources: https://github.com/andysnell/enums-phps-missing-data-type
- 486