Strongly Typed Arrays and Lists in PHP

Improve your PHP programming speed by using Typed Lists

What we are going to accomplish

In this article I'm going to show you how you can have full auto complete support for array objects in PHP, allowing you to auto complete your way through your development tasks, improving your code, and reducing time and errors.

programming Console
Late night programming sessions
Cory Marsh
Cory Marsh
Share:
Cory Marsh has over 20 years Internet security experience. He is a lead developer on the BitFire project and regularly releases PHP security and programming videos on BitFire's you tube channel.

The problem with PHP arrays

PHP arrays are fantastic, simple to use data structures. They can store lists of elements or can be used as hash map style data structures. PHP can even convert a list to a map and a map back to a list.

<?php
// array as list of numbers
$list_array[] = 1;
$list_array[] = 2;

// map as names to numbers
$map_array[1] = "one";
$map_array[2] = "two";

$values = array_values($map_array);
// prints "one" "two"
print_r($values);

// prints "one" "two"
$new_map = array_combine($list_array, $values);

// prints 1 => "one", 2 => "two"
print_r($values);
?>

The one thing that PHP arrays do not have is strongly typed values. Most modern programming languages have a feature called "generics" or in C++ "templates" that restricts an array or list to a specific type. This serves multiple purposes. It allows the programmer to know exactly what type and shape the data is in the list. It ensures that unexpected data does not slip into the list. It allows the compiler to enforce these checks at compile time and reduces the load by removing these checks at runtime.

This means that when you are passed an array to a function or method say named "'users", you have no way of knowing what "users" is. Is it a list of user ids? Maybe user objects? Perhaps it is a list of user names, or maybe it is a map of user ids to usernames. PHP IDEs don't have type information about the array so you are forced to trace down the call stack and find what the original data is. Or maybe you just add a print statement or run the code through a debugger to find the type.

The Idea

While PHP does not support generics, you can create typed list that are supported by PHP >7.1 that can be used exactly like arrays but have associated type information for the PHP interpreter and your IDE.

The secret is the ArrayAccess and Iterator interfaces. Any class implementing these interfaces can be used exactly like any array object in PHP. This means you can have a list class that implements ArrayAccess and Iterator that can be used anywhere an array can.

<?php
class Typed_List implements ArrayAccess, Iterator { // ... }

$list = new Typed_List();
$list[] = "item 1";
$list[] = "item 2";

// prints "item 1" "item 2"
foreach ($list as $element) {
  echo "$element\n";
}
?>

That's great, but how does that help us solve the problem of typed arrays?

The Implementation Magic

The magic comes in when we fill out the implementation for Typed_List. By filling in all required functions for our two interfaces and leaving the two getter methods offsetGet() and current() abstract we can specify the real return type in our concrete sub class and our IDE and PHP interpreter will now have full type information for the array.

<?php
class User {
  public string $name;
  public string $email;
}

class User_List extends Typed_List {

  // called when accessed like echo $list[$offset];
  public function offsetGet($offset) : User {
    return $this->list[$offset];
  }

  // called when accessed like foreach($list as $item) { // $item is type User }
  public function current() : User {
    return $this->list[$this->position];
  }
}?>

Notice how the return type of both methods is type User. That is how the PHP Interpreter and your IDE knows exactly what the type of the array element is. Now when you iterate over your list of Users, your IDE will know that any element in the list is a User object and allow you to auto-complete $name and $email. Brilliant!

Full Implementation

Go ahead and try this in your own code. I have included a stand alone class you can copy paste into your project to create your own Typed lists. Simply extend this class, implement the two abstract methods as shown and watch the IDE magic.

<?php
/**
 * a <generic> list. This is the base class for all typed lists 
 * Extend this abstract class and implement:
 * offsetGet() : Your_Return_Type and current() : Your_Return_Type
 * and then use your new subclass of type Your_Return_Type.
 * EG:
 * class User_List extends Typed_List {
 *   public function offsetGet($index) : User {
 *     return $this->protected_get($index);
 *   }
 *   public function current() : User {
 *     return $this->protected_get($this->_position);
 *   }
 * }
 */
abstract class Typed_List implements \ArrayAccess, \Iterator, \Countable, \SeekableIterator {

    protected string $_type = "mixed";
    protected $_position = 0;
    protected array $_list;

    private bool $_is_associated = false;
    private $_keys;

    
    public function __construct(array $list = []) {
        $this->_list = $list;
        $this->_type = $this->get_type();
    }

    /**
     * Example: echo $list[$index]
     * @param mixed $index index may be numeric or hash key
     * @return $this->_type cast this to your subclass type
     */
    #[\ReturnTypeWillChange]
    public abstract function offsetGet($index);

    /**
     * Example: foreach ($list as $key => $value)
     * @return mixed cast this to your subclass type at the current iterator index
     */
    #[\ReturnTypeWillChange]
    public abstract function current();


    /**
     * @return string the name of the type list supports or mixed
     */
    public abstract function get_type() : string;


    /**
     * return a new instance of the subclass with the given list
     * @param array $list 
     * @return static 
     */
    public static function of(array $list) : static {
        return new static($list);
    }

    /**
     * clone the current list into a new object
     * @return static new instance of subclass
     */
    #[\ReturnTypeWillChange]
    public function clone() {
        $new = new static();
        $new->_list = array_clone($this->_list);
        return $new;
    }

    /**
     * Example count($list);
     * @return int<0, \max> - the number of elements in the list
     */
    public function count(): int {
        return count($this->_list);
    }

    /**
     * SeekableIterator implementation
     * @param mixed $position - seek to this position in the list
     * @throws OutOfBoundsException - if the element does not exist
     */
    public function seek($position) : void {
        if (!isset($this->_list[$position])) {
            throw new OutOfBoundsException("invalid seek position ($position)");
        }
  
        $this->_position = $position;
    }

    /**
     * SeekableIterator implementation. seek internal pointer to the first element
     * @param mixed $position - seek to this position in the list
     */
    public function rewind() : void {
        if ($this->_is_associated) {
            $this->_keys = array_keys($this->_list);
            $this->_position = array_shift($this->_keys);
        } else {
            $this->_position = 0;
        }
    }

    /**
     * SeekableIterator implementation. equivalent of calling current()
     * @return mixed - the pointer to the current element
     */
    public function key() : mixed {
        return $this->_position;
    }

    /**
     * SeekableIterator implementation. equivalent of calling next()
     */
    public function next(): void {
        if ($this->_is_associated) {
            $this->_position = array_shift($this->_keys);
        } else {
            ++$this->_position;
        }
    }

    /**
     * SeekableIterator implementation. check if the current position is valid
     */
    public function valid() : bool {
        if (isset($this->_list[$this->_position])) {
            if ($this->_type != "mixed") {
                return $this->_list[$this->_position] instanceof $this->_type;
            }
            return true;
        }
        return false;
    }

    /**
     * Example: $list[1] = "data";  $list[] = "data2";
     * ArrayAccess implementation. set the value at a specific index
     * @throws 
     */
    public function offsetSet($index, $value) : void {
        // type checking
        if ($this->_type != "mixed") {
            if (! $value instanceof $this->_type) {
                $msg = get_class($this) . " only accepts objects of type \"" . $this->_type . "\", \"" . gettype($value) . "\" passed";
                throw new InvalidArgumentException($msg, 1);
            }
        }
        if (empty($index)) {
            $this->_list[] = $value;
        } else {
            $this->_is_associated = true;
            if ($index instanceof Hash_Code) {
                $this->_list[$index->hash_code()] = $value;
            } else {
                $this->_list[$index] = $value;
            }
        }
    }

    /**
     * unset($list[$value]);
     * ArrayAccess implementation. unset the value at a specific index
     */
    public function offsetUnset($index) : void {
        unset($this->_list[$index]);
    }

    /**
     * ArrayAccess implementation. check if the value at a specific index exist
     */
    public function offsetExists($index) : bool {
        return isset($this->_list[$index]);
    }

    /**
     * example $data = array_map($fn, $list->raw());
     * @return array - the internal array structure
     */
    public function &raw() : array {
        return $this->_list;
    }


    /**
     * sort the list
     * @return static - the current instance sorted
     */
    public function ksort(int $flags = SORT_REGULAR): static {
        ksort($this->_list, $flags);
        return $this;
    }

    /**
     * @return bool - true if the list is empty
     */
    public function empty() : bool {
        return empty($this->_list);
    }

    /**
     * helper method to be used by offsetGet() and current(), does bounds and key type checking
     * @param mixed $key 
     * @throws OutOfBoundsException - if the key is out of bounds
     */
    protected function protected_get($key) {
        if ($this->_is_associated) {
            if (isset($this->_list[$key])) {
                return $this->_list[$key];
            }
        }
        else {
            if ($key <= count($this->_list)) {
                return $this->_list[$key];
            }
        }

        throw new OutOfBoundsException("invalid key [$key]");
    }


   /**
     * filter the list using the given function 
     * @param callable $fn 
     * @return static
     */
    public function filter(callable $fn, bool $clone = false) {
        assert(fn_takes_x_args($fn, 1), last_assert_err() . " in " . get_class($this) . "->map()"); 
        assert(fn_arg_x_is_type($fn, 0, $this->_type), last_assert_err() . " in " . get_class($this) . "->map()");
        if ($clone) {
            return new static(array_filter(array_clone($this->_list), $fn));
        }
        $this->_list = array_filter($this->_list, $fn);
        return $this;
    }

    /**
     * json encoded version of the list
     * @return string json encoded version of first 5 elements
     */
    public function __toString() : string {
        return json_encode(array_slice($this->_list, 0, 5));
    }
}?>
Cory Marsh
Cory Marsh
Share:
Cory Marsh has over 20 years Internet security experience. He is a lead developer on the BitFire project and regularly releases PHP security and programming videos on BitFire's you tube channel.