Built In Types: Shapes

A shape consists of a group of zero or more data fields taken together as a whole. A shape type is an unordered set of fields each of which has a name and an associated type. For example:

$point1 = shape('x' => -3, 'y' => 6);
function set_origin(shape('x' => int, 'y' => int) $p): void { ... }
function get_origin(): shape('x' => int, 'y' => int) { ... }

Here, we mimic a point with integer coordinates, by having a shape with two fields called x and y, each of which is an int. A shape value has the form of a comma-separated list of name/value pairs delimited with parentheses and preceded by shape, as in shape('x' => -3, 'y' => 6) above. As we can quickly deduce, that shape has type shape of two fields, both of type int, in some unknown order, and that is the type of the argument expected by function set_origin, and returned by function get_origin.

As the ordering of the fields is irrelevant, given the following:

shape('x' => -3, 'y' => 6)
shape('y' => 6, 'x' => -3)

the two shape values are identical.

A field in a shape is accessed using its name as the key in a subscript expression. For example:

function point_to_string(shape('x' => int, 'y' => int) $p): string {
  return '(' . $p['x'] . ',' . $p['y'] . ')';
$point1 = shape('x' => -3, 'y' => 6);
$s = point_to_string($point1);    // $s takes on the value "(-3,6)"

A field whose name is preceded by ? is optional, and need not be mentioned in any initializer of, or assignment to, a variable of that type; however, until its value is set explicitly, that field does not actually exist in the shape. Consider the following:

function f(shape('a' => int, ?'n' => string) $p): void {
  echo "\$p['a']: " . $p['a'] . "\n";
  echo "\$p['n']: " . $p['n'] . "\n";  // only permitted if n exists

Given the call f(shape('a' => 10, 'n' => "xxx")), field n has its value set explicitly, and f works fine. However, given the call f(shape('a' => 10)), field n does not have its value set explicitly, in which case, attempting to access that field using $p['n'] results in an "undefined index" error at runtime. To be certain such accesses succeed, first call Shapes::keyExists. (See the library class Shapes.)

Here are some more, simple, shape-type examples:

shape('real' => float, 'imag' => float)
shape('id' => string, 'url' => string, 'count' => int)
shape('name' => string, 'address' => shape('street' => string,
  'city' => string, 'state' => string, 'postcode' => int))

In the final case above, we have a shape within a shape.

Consider a shape type S2 whose field set is a superset of that in shape type S1. As such, S2 is a subtype of S1. (See the banking example below.) However, when an S2 is used as an S1, only the S1 fields in that S2 are accessible.

For non-trivial shape types (like the name and address one above), it can be cumbersome to write out the complete type. Fortunately, Hack provides a type-aliasing capability via type (and newtype), which is demonstrated in the next example:

<?hh // strict

namespace Hack\UserDocumentation\Types\Shapes\Examples\Banking;

enum Bank: int {
  DEPOSIT = 1;

type Transaction = shape('trtype' => Bank, ...);
type Deposit = shape('trtype' => Bank, 'toaccnum' => int, 'amount' => float);
type Withdrawal = shape('trtype' => Bank, 'fromaccnum' => int, 'amount' => float);
type Transfer = shape('trtype' => Bank, 'fromaccnum' => int, 'toaccnum' => int, 'amount' => float);

function main(): void {
  process_transaction(shape('trtype' => Bank::DEPOSIT, 'toaccnum' => 23456, 'amount' => 100.00));
  process_transaction(shape('trtype' => Bank::WITHDRAWAL, 'fromaccnum' => 3157, 'amount' => 100.00));
  process_transaction(shape('trtype' => Bank::TRANSFER, 'fromaccnum' => 23456,
   'toaccnum' => 3157, 'amount' => 100.00));

function process_transaction(Transaction $t): void {
  $ary = Shapes::toArray($t);
  switch ($t['trtype']) {
  case Bank::TRANSFER:
    echo "Transfer: " . ((string)$ary['amount'])
      . " from Account " . ((string)$ary['fromaccnum'])
      . " to Account " . ((string)$ary['toaccnum']) . "\n";
    // ...

  case Bank::DEPOSIT:
    echo "Deposit: " . ((string)$ary['amount'])
      . " to Account " . ((string)$ary['toaccnum']) . "\n";
    // ...

  case Bank::WITHDRAWAL:
    echo "Withdrawal: " . ((string)$ary['amount'])
      . " from Account " . ((string)$ary['fromaccnum']) . "\n";
    // ...
Deposit: 100 to Account 23456
Withdrawal: 100 from Account 3157
Transfer: 100 from Account 23456 to Account 3157

Type Transaction is a sort-of abstract type that only ever contains the transaction-type field. The ... notation indicates that there may be other, optional fields. This allows the types Deposit, Withdrawal, and Transfer to be considered subtypes of Transaction by having the same first field, and then adding other fields as well.

Note carefully, that inside function process_transaction, even though the transaction passed in might have been a Deposit, a Withdrawal, or a Transfer, it always appears as a Transaction, so the only field we can access in $t is trtype. However, using Shapes::toArray, we can convert the Transaction to an array, and then get read-access to the field values we know that array must contain by indexing it using the field names, as shown.