DEV Community

Cover image for Reinforce the type safety of your PHP arrays
Anwar
Anwar

Posted on • Updated on

Reinforce the type safety of your PHP arrays

Welcome to this post where I will show you how to bring more type safety to your arrays in PHP.

Summary

What is the problem?

In my experience it is very rare to have list of multiple type of data within an array. Most of the time, we want to have a list of something.

use App\Services\User;

$cards = (new User())->getSavedCreditCards();

print_r($cards);
Enter fullscreen mode Exit fullscreen mode
[
  [
    "id" => "YTZERT37RR3TR7",
    "type" => "amex",
    "lastFour" => "0826",
    "expiry" => [
      "month" => "04",
      "year" => "2024",
    ],
  ],
  [
    "id" => "83E38Y38YF8FYF3",
    "type" => "visa",
    "lastFour" => "1162",
    "expiry" => [
      "month" => "02",
      "year" => "2023"
    ],
  ],
]
Enter fullscreen mode Exit fullscreen mode

Let us imagine the UserService takes data from our configured payment service provider of choice, and this is actually the raw response that is returned on the UserService::getSavedCreditCards().

There is several problem having to deal with this kind of response:

  • You are exposed to "undefined array key..." errors
  • Your IDE cannot help you know what is the key name (if you will have to go on the source code to find the array shape, if you are lucky and it is not burried inside the PSP vendor code)
  • It is not guaranteed the array will only contain a list of stored cards

Typing our items

In this example objects would help convey more information in the code and bring type safety.

Let us start with a basic object representing a credit card.

readonly class Card
{
  public function __construct(
    public string $id,
    public CardType $type,
    public string $lastFour,
    public CardExpiry $expiry,
  ) {}
}

readonly class CardExpiry
{
  public function __construct(
    public string $month,
    public string $year,
  ) {}
}

enum CardType: string
{
  case AmericanExpress = "amex";
  case Visa = "visa";
  case MasterCard = "mastercard";
}
Enter fullscreen mode Exit fullscreen mode

Now we could stop there and create an array of Card.

$cards = [
  new Card(
    id: "YTZERT37RR3TR7",
    type: CardType::AmericanExpress,
    lastFour: "0826",
    expiry: new CardExpiry(
      month: "04",
      year: "2024",
    ),
  ),
  new Card(
    id: "83E38Y38YF8FYF3",
    type: CardType::Visa,
    lastFour: "1162",
    expiry: new CardExpiry(
      month: "02",
      year: "2023",
    ),
  ),
];
Enter fullscreen mode Exit fullscreen mode

At this point, nothing prevents us from adding something else than a Card in this array. It is time to introduce "array objects".

Using objects as type safe array

Let us start with a class that holds a list of Card.

class Cards
{
  private array $cards;

  public function __construct()
  {
    $this->cards = [];
  }
}
Enter fullscreen mode Exit fullscreen mode

To be able to push values to an object as if it was an array, let us implement the ArrayAccess interface.

class Cards implements ArrayAccess
{
  private array $cards;

  public function __construct()
  {
    $this->cards = [];
  }

  // Array access methods
  public function offsetExists(mixed $offset): bool
  {
    return isset($this->cards[$offset]);
  }

  public function offsetGet($offset): ?Card
  {
    return $this->cards[$offset] ?? null;
  }

  public function offsetSet(mixed $offset, mixed $value): void
  {
    if (!$value instanceof Card) {
      throw new InvalidArgumentException("Expected parameter 2 to be a Card.");
    }

    $this->cards[$offset] = $value;
  }

  public function offsetUnset($offset): void 
  {
    if (isset($this->cards[$offset])) {
      unset($this->cards[$offset]);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we can safely push Card into the array.

$cards = new Cards();

$cards[] = new Card(
  id: "YTZERT37RR3TR7",
  type: CardType::AmericanExpress,
  lastFour: "0826",
  expiry: new CardExpiry(
    month: "04",
    year: "2024",
  ),
);

$cards[] = 1; // Uncaught InvalidArgumentException: Expected parameter 2 to be a Card.
Enter fullscreen mode Exit fullscreen mode

To be able to loop through the array, we need to implement another built-in interface.

class Cards implements ArrayAccess, IteratorAggregate
{
  private array $cards;

  public function __construct()
  {
    $this->cards = [];
  }

  // Iterator aggregate methods
  public function getIterator(): ArrayIterator
  {
    return new ArrayIterator($this->cards);
  }

  // Array access methods
  public function offsetExists(mixed $offset): bool
  {
    return isset($this->cards[$offset]);
  }

  public function offsetGet($offset): ?Card
  {
    return $this->cards[$offset] ?? null;
  }

  public function offsetSet(mixed $offset, mixed $value): void
  {
    if (!$value instanceof Card) {
      throw new InvalidArgumentException("Expected parameter 2 to be a Card.");
    }

    $this->cards[$offset] = $value;
  }

  public function offsetUnset($offset): void 
  {
    if (isset($this->cards[$offset])) {
      unset($this->cards[$offset]);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And now we can loop through our cards.

$cards = new Cards();

$cards[] = new Card(
  id: "YTZERT37RR3TR7",
  type: CardType::AmericanExpress,
  lastFour: "0826",
  expiry: new CardExpiry(
    month: "04",
    year: "2024",
  ),
);

foreach ($cards as $card) {
  echo $card->expiry->month;  // "04"
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

This "array object" is the equivalent of a "Set" in other language: it ensures the list have a consistent type across all its items.

One downside is that this check is done at runtime (unless you keep reading). The PHP interpreter will not natively understand your $cards array is a list of Card. Your IDE will still consider $card as a mixed. The last trick is to use the assert() function to give an extra help to your IDE.

foreach ($cards as $card) {
  assert($card instanceof Card);

  echo $card->expiry->month;  // "04", IDE autocompletion works
}
Enter fullscreen mode Exit fullscreen mode

Static analysis tools would prevent you from assigning an int to the lis of Card, with some extra PHPDoc blocks.

/**
 * @implements ArrayAccess<int, Card>
 * @implements IteratorAggregate<int, Card>
 */
class Cards implements ArrayAccess, IteratorAggregate
{
  // ...
}

$cards = new Cards();

$cards[] = 1; // PHPStan will prevent you from doing this now
Enter fullscreen mode Exit fullscreen mode

You can try what PHPStan tells you online with this pre-filled example.

Now you can ensure type consistency across your lists, which gives an extra layer of safety, and can be a great ally when working in teams.

At some point I hope this article will be deprecated in favor of generics within PHP. A proposal already thrown the basis for something like this.

$cards = array<Card>();

$cards[] = new Card(...);
$cards[] = 1; // Both PHPstan and PHP would throw
Enter fullscreen mode Exit fullscreen mode

In the mean time, I hope you will leave with some ideas to make your code base more robust. Thank you for reading!

Happy type hardening 🛡️

Cover image by Pixabay.

Top comments (9)

Collapse
 
sean_kegel profile image
Sean Kegel

Nice article. I think this is a good use case for Laravel's new ensure collection method to make sure each item in a collection is a specific type. Though I wonder what the performance implications might be there checking the entire collection versus checking on add.

laravel.com/docs/10.x/collections#...

Collapse
 
anwar_nairi profile image
Anwar

Nice I forgot about this one 😅 I think the cost would be quite similar as ensure would stop at the first non coherent type VS throwing as soon as a bad type is pushed in the array, the only difference would be throwing at the assignation level VS throwing when calling ->ensure() I guess 🤔

Collapse
 
klabauterjan profile image
JanWennrich

This "array object" is the equivalent of a "Set" in other language

I disagree with this statement. A set is defined like the following:

A set is a data structure that stores unique elements of the same type in a sorted order

The "array object" does not store unique elements. An element could exist twice in the array.

Instead the "array object"resembles somewhat of a collection data structure:

A Collection is any data structure that can hold zero or more data items. Generally, the data items will be of the same type or, in languages supporting inheritance, derived from some common ancestor type

Collapse
 
anwar_nairi profile image
Anwar

You absolutely right!

Collapse
 
suckup_de profile image
Lars Moelleken

In the current PhpStorm version you don't need the assert call anymore, you just use the phpstan annotation and it works. 👍

PS: I created an abstract collection library some time ago, so you don't have to implement the ArrayAccess methods every time: github.com/voku/Arrayy#collections

Collapse
 
dopitz profile image
Daniel O.

The Card class implements the getIterator method twice. So it will give the following error: Fatal error: Cannot redeclare Cards::getIterator(). Just had to remove one of them. Thanks for the article.

Collapse
 
anwar_nairi profile image
Anwar

Fixed good catch Daniel!

Collapse
 
marieintheworld profile image
Marieintheworld

If you want to improve your PHP skills I recommend to you this youtube channel . This gay is my friend and he is a professional PHP developer.

Collapse
 
anwar_nairi profile image
Anwar

I see your friend just created his channel, I wish him good luck and keep up the good video release frequency!