Skip to content

Latest commit

 

History

History
318 lines (230 loc) · 9.85 KB

array_types.md

File metadata and controls

318 lines (230 loc) · 9.85 KB

Array types

In PHP, the array type is commonly used to represent three different data structures:

List:

<?php
$a = [1, 2, 3, 4, 5];

Associative array:

<?php
$a = [0 => 'hello', 5 => 'goodbye'];
$b = ['a' => 'AA', 'b' => 'BB', 'c' => 'CC']

Makeshift Structs:

<?php
$a = ['name' => 'Psalm', 'type' => 'tool'];

PHP treats all these arrays the same, essentially (though there are some optimisations under the hood for the first case).

Psalm has a few different ways to represent arrays in its type system:

Click on the » next to each type to view detailed documentation and examples.

Generic arrays

Psalm uses a syntax borrowed from Java that allows you to denote the types of both keys and values:

/** @return array<TKey, TValue> */

You can also specify that an array is non-empty with the special type non-empty-array<TKey, TValue>.

PHPDoc syntax

PHPDoc allows you to specify the type of values a generic array holds with the annotation:

/** @return ValueType[] */

In Psalm this annotation is equivalent to @psalm-return array<array-key, ValueType>.

Generic arrays encompass both associative arrays and lists.

Lists

(Psalm 3.6+)

Psalm supports a list type that represents continuous, integer-indexed arrays like ["red", "yellow", "blue"].

A frequent way to create a list is with the $arr[] = notation.

These arrays will return true to array_is_list($arr)(PHP 8.1+) and represent a large percentage of all array usage in PHP applications.

A list type is of the form list<SomeType>, where SomeType is any permitted union type supported by Psalm.

  • list is a subtype of array<int, mixed>
  • list<Foo> is a subtype of array<int, Foo>.

List types show their value in a few ways:

<?php
/**
 * @param array<int, string> $arr
 */
function takesArray(array $arr) : void {
  if ($arr) {
     // this index may not be set
    echo $arr[0];
  }
}

/**
 * @psalm-param list<string> $arr
 */
function takesList(array $arr) : void {
  if ($arr) {
    // list indexes always start from zero,
    // so a non-empty list will have an element here
    echo $arr[0];
  }
}

takesArray(["hello"]); // this is fine
takesArray([1 => "hello"]); // would trigger bug, without warning

takesList(["hello"]); // this is fine
takesList([1 => "hello"]); // triggers warning in Psalm

Object-like arrays

Psalm supports a special format for arrays where the key offsets are known: object-like arrays, also known as array shapes.

Given an array

<?php
["hello", "world", "foo" => new stdClass, 28 => false];

Psalm will type it internally as:

strict-array{0: string, 1: string, foo: stdClass, 28: false}

You can specify types in that format yourself, e.g.

/** @return strict-array{foo: string, bar: int} */

Optional keys can be denoted by a trailing ?, e.g.:

/** @return strict-array{optional?: string, bar: int} */

Starting from psalm v5, psalm also supports a special format for lists where the key offsets are known: object-like lists, also known as list shapes.

Given a list

<?php
["hello", "world", new stdClass, false];

Psalm will type it internally as:

strict-list{string, string, stdClass, false}

You can specify types in that format yourself, e.g.

/** @return strict-list{string, int} */
/** @return strict-list{0: string, 1: int} */

Optional keys can be denoted by a specifying keys for all elements and specifying a trailing ? for optional keys, e.g.:

/** @return strict-list{0: string, 1?: int} */

Starting from Psalm v5, object-like arrays and lists can be marked as sealed with a strict- prefix, which means Psalm can reason a lot better about them, see here for more info.

Sealed object-like arrays

Starting from Psalm v5, object-like arrays and lists can be marked as sealed with a strict- prefix.
Sealed arrays can only have only have the keys specified in the shape: extra keys are forbidden.

This additional simplicity means Psalm can reason a lot better about them:

/** @var strict-array{foo: string, bar?: int, baz?: float} $arr */

if (count($arr) > 2) {
  echo $arr['baz']; // OK!
}

// Off by one bug, detected by Psalm:
if (count($arr) > 3) {
  // DocblockTypeContradiction - Docblock-defined type for $arr is never has-at-least-4
}

/** @param strict-array{0: float, 1: float, 2?: float} $arr */
function avgShape(array $arr): float {
  return array_sum($arr) / count($arr);
}

// InvalidArgument - Argument 1 of avgShape expects ...
avgShape([123.1, 321.0, 1.0, new class {}, 'test']);

The above examples contain bugs which can be detected by Psalm only when using sealed arrays.

The counterpart to sealed arrays are unsealed arrays », generated as intermediate types when asserting raw arrays.
Unsealed arrays are by definition uncertain, so Psalm can't reason well about them: always convert them to sealed arrays as specified here ».

Tip: if you find yourself copying the same complex sealed array shape over and over again to avoid InvalidArgument issues, try using type aliases, instead.

Unsealed object-like arrays

Starting from v5, Psalm defines a supertype of object-like arrays called unsealed object-like arrays.
This type is used in cases where an object-like array may have extra keys not specified in the shape.
Avoid using unsealed arrays in your codebase, as they can cause undetectable bugs: always transform them into sealed arrays before use.
Unsealed arrays are by definition uncertain, so, unlike sealed arrays, Psalm can't reason well about them: always use sealed arrays in your PHPDocs.

Here's how unsealed arrays can cause weird bugs:

<?php

/**
 * @param array{a: float, b: float} $params
 */
function avg(array $params): float {
  return array_sum($params) / 2.0;
}

$arr = json_decode(file_get_contents('https://.../'), true);

if (is_array($arr)
  && isset($arr['a']) && is_float($arr['a'])
  && isset($arr['b']) && is_float($arr['b'])
) {
  /** @psalm-trace $array */; // array{a: float, b: float}
  echo avg($arr);
}

/**
 * @param strict-array{a: float, b: float, c: float} $params
 */
function avgCoefficient(array $params): float {
  return avg($params) * $params['c'];
}

In this example, we assume that avg takes an array with two elements, but what happens if the API (or some other function) also provides a third c parameter to a function that averages two elements?
And what would happen if a string requestId parameter is returned by the API call?

Clearly, just asserting the shape of the array is not enough: we need to guarantee that the array will contain only the array elements we need.

<?php

/**
 * @param strict-array{a: float, b: float} $params
 */
function avg(array $params): float {
  return array_sum($params) / 2.0;
}

$arr = json_decode(file_get_contents('https://.../'), true);

if (is_array($arr)
  && isset($arr['a']) && is_float($arr['a'])
  && isset($arr['b']) && is_float($arr['b'])
  && count($arr) === 2 // <-- Ensure only two elements are present
) {
  /** @psalm-trace $array */; // strict-array{a: float, b: float}
  echo avg($arr);
}

/**
 * @param strict-array{a: float, b: float, c: float} $params
 */
function avgCoefficient(array $params): float {
  // InvalidArgument - Argument 1 of avg expects strict-array{a: float, b: float}, but strict-array{a: float, b: float, c: float} provided
  //return avg($params) * $params['c'];

  $coefficient = $params['c'];
  unset($params['c']);
  return avg($params) * $coefficient;
}

You can also manually provide a ['a' => $arr['a'], 'b' => $arr['b']], but there's an even better way to seamlessly validate user-provided input:

Use Valinor in strict mode to easily assert sealed arrays @ runtime using Psalm array shape syntax (instead of manually asserting keys with isset):

try {
  $array = (new \CuyZ\Valinor\MapperBuilder())
      ->mapper()
      ->map(
          'strict-array{a: string, b: int}',
          json_decode(file_get_contents('https://.../'), true)
      );

  /** @psalm-trace $array */; // strict-array{a: string, b: int}

  echo $array['a'];
  echo $array['b'];
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
  // Do something…
}

Valinor provides both runtime and static Psalm assertions with full Psalm syntax support and many other features, check out the Valinor documentation for more info!

Tip: if you find yourself copying the same complex sealed array shape over and over again to avoid InvalidArgument issues, try using type aliases, instead.

Callable arrays

An array holding a callable, like PHP's native call_user_func() and friends supports it:

<?php

$callable = ['myClass', 'aMethod'];
$callable = [$object, 'aMethod'];

non-empty-array

An array which is not allowed to be empty. Generic syntax is also supported: non-empty-array<string, int>.