Type Constants: Introduction

Imagine that you have a non-generic class, and some various extends to that class.

<?hh

namespace Hack\UserDocumentation\TypeConstants\Intro\Examples\NonParameterized;

abstract class User {
  public function __construct(private int $id) {}
  public function getID(): int {
    return $this->id;
  }
}

trait UserTrait {
  require extends User;
}

interface IUser {
  require extends User;
}

class AppUser extends User implements IUser {
  use UserTrait;
}

function run(): void {
  $au = new AppUser(-1);
  var_dump($au->getID());
}

run();
Output
int(-1)

Now imagine that you realize that sometimes the ID of a user could be a string as well as an int. But you know that the concrete classes of User will know exactly what type will be returned.

Generics introduces the notion of type parameters which basically allows you to associate a type placeholder to a class or method, which is then fully associated once the class is instantiated or the method is called.

<?hh

namespace Hack\UserDocumentation\TypeConstants\Intro\Examples\Generics;

abstract class User<T as arraykey> {
  public function __construct(private T $id) {}
  public function getID(): T {
    return $this->id;
  }
}

trait UserTrait<T as arraykey> {
  require extends User<T>;
}

interface IUser<T as arraykey> {
  require extends User<T>;
}

// We know that AppUser will only have int ids
class AppUser extends User<int> implements IUser<int> {
  use UserTrait<int>;
}

class WebUser extends User<string> implements IUser<string> {
  use UserTrait<string>;
}

class OtherUser extends User<arraykey> implements IUser<arraykey> {
  use UserTrait<arraykey>;
}

function run(): void {
  $au = new AppUser(-1);
  var_dump($au->getID());
  $wu = new WebUser('-1');
  var_dump($wu->getID());
  $ou1 = new OtherUser(-1);
  var_dump($ou1->getID());
  $ou2 = new OtherUser('-1');
  var_dump($ou2->getID());
}

run();
Output
int(-1)
string(2) "-1"
int(-1)
string(2) "-1"

Notice how we had to propagate the addition of a type parameter to the the class itself and all that extended it. Now think if we had hundreds and hundreds of places that used the traits and interfaces; we would have to update them as well.

When it comes to class type parameterization, Hack introduces an alternative feature to generics called type constants. Instead of types being declared as parameters directly on the class itself, type constants allow the type to be declared as class member constants instead.

<?hh

namespace Hack\UserDocumentation\TypeConstants\Intro\Examples\TypeConstants;

abstract class User {
  abstract const type T as arraykey;
  public function __construct(private this::T $id) {}
  public function getID(): this::T {
    return $this->id;
  }
}

trait UserTrait {
  require extends User;
}

interface IUser {
  require extends User;
}

// We know that AppUser will only have int ids
class AppUser extends User implements IUser {
  const type T = int;
  use UserTrait;
}

class WebUser extends User implements IUser {
  const type T = string;
  use UserTrait;
}

class OtherUser extends User implements IUser {
  const type T = arraykey;
  use UserTrait;
}

function run(): void {
  $au = new AppUser(-1);
  var_dump($au->getID());
  $wu = new WebUser('-1');
  var_dump($wu->getID());
  $ou1 = new OtherUser(-1);
  var_dump($ou1->getID());
  $ou2 = new OtherUser('-1');
  var_dump($ou2->getID());
}

run();
Output
int(-1)
string(2) "-1"
int(-1)
string(2) "-1"

Notice the syntax abstract const type <name> [ as <constraint> ];. All type constants are const and use the keyword type. You specify a name for the constant, along with any possible constraints that must be adhered to. See below for information about syntax.

Notice too that only the class itself and direct children needed to be updated with the new type information.

Type constants are a bit analogous to abstract methods, where base classes define method signatures without bodies, and subclasses provide the actual implementations.

Syntax

The syntax for a type constant depends on whether you are in an abstract or concrete class.

Abstract class

In an abstract class, the syntax is the most straightforward:

abstract const type <name> [as <constraint>]; // constraint optional

For example:

abstract class A {
  abstract const type Foo;
  abstract const type Bar as arraykey;
}

Then in concrete children of that subclass:

class C extends A {
  const type Foo = string;
  // Has to be int or string since was constrained to arraykey
  const type Bar = int;
}

Concrete class

You can declare a type constant in a concrete class, but it requires different syntax:

const type <name> [as <constraint>] = <type>; // constraint optional

For example:

class NoChild {
  const type Foo = ?string;
}

class Parent {
  const type Foo as arraykey = arraykey; // need constraint for child override
}

class Child extends Parent {
  const type Foo = string; // a string is an arraykey, so ok
}

Using Type Constants

Given that the type constant is a first-class constant of the class, you can reference it using this. As a type annotation, you annotate a type constant like:

this::<name>

e.g.,

this::T

You can think of this:: in a similar manner as the this return type.

This example shows the real benefit of type constants. The property is defined in Base, but can have different types depending on the context of where it is being used.

<?hh

namespace Hack\UserDocumentation\TypeConstants\Introduction\Examples\Annotate;

abstract class Base {
  abstract const type T;
  protected this::T $value;
}

class Stringy extends Base {
  const type T = string;
  public function __construct() {
    // inherits $value in Base which is now setting T as a string
    $this->value = "Hi";
  }
  public function getString(): string {
    return $this->value; // property of type string
  }
}

class Inty extends Base {
  const type T = int;
  public function __construct() {
    // inherits $value in Base which is now setting T as an int
    $this->value = 4;
  }
  public function getInt(): int {
    return $this->value;  // property of type int
  }
}

function run(): void {
  $s = new Stringy();
  $i = new Inty();
  var_dump($s->getString());
  var_dump($i->getInt());
}

run();
Output
string(2) "Hi"
int(4)

Other Rules

There are some other rules with respect to type constants:

  • Like class constants, type constants have public visibility.
  • Outside the immediate class hierarchy of where a type constant is declared, you can refer to them via classname::typeConstantName (e.g., Foo::T).
  • Like generics, type constants can only be used in type annotations. They cannot be used in other language constructs like new, instanceof().