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

  • 89
Loading comments...

More from Andy Snell