Types: With Refinement
Besides is
-expressions, Hack supports another form of type
refinements, which we refer to as with
-refinements in this section.
This feature allows more precise typing of classes/interfaces/traits in
a way that specific type or context constant(s) are more specific
(i.e., refined).
For example, given the definition
interface Box {
abstract const type T;
abstract const ctx C super [defaults];
public function get()[this::C]: this::T;
}
one can write a function for which Hack statically guarantees the
returned Set
is
valid, i.e., it
only contains integers and/or strings, and not objects of any other type:
function unwrap_as_singleton_set<Tb as arraykey>(
Box with { type T = Tb } $int_or_string_box
): Set<Tb> {
return Set { $int_or_string_box->get() };
}
Independently, one can constrain context C
in Box
. For example, to
work with Box
subtypes which implement the get
method in a pure
way (without side effects), with
-refinements can be used as follows:
function unwrap_pure<Tb>(
Box with { ctx C = []; type T = Tb } $box,
)[]: Tb {
return $box->get(); // OK (type-checker knows `get` must have the empty context list)
}
A notable use case unlocked by this feature is that a with
-refinement
can appear in return positions, e.g.:
// API
function wrap_number(num $x): Box with { type T = num } {
return new IntBox($x);
}
// implementation details (subject to change):
class IntBox implements Box {
const type T = num;
const ctx C = [];
public function __construct(private this::T $v) {}
public function get()[]: this::T { return $this->v; }
}
This is something that is inexpressible with where-clauses.
Loose (as
, super
) bounds on refined constants, such as type T
and context C
, are also supported. For example, you can write
functions statically safe functions such as:
function boxed_sum(
Traversable<Box with { type T as num }> $numeric_boxes
): float {
$sum = 0.0;
foreach ($numeric_boxes as $nb) {
$sum += $nb->get();
}
return $sum;
}
and avoid assertions that objects returned by Box’s get
methods are
numbers (int
or float
).
Finally, you can also use generics in bounds; e.g., the above function could have signature
function boxed_sum_generic<T as num>(
Traversable<Box with { type T = T }> $numeric_boxes
): T /* or float */
Sound alternative to TGeneric::TAbstract
This section shows how to improve type safety of existing Hack code that employs a common pattern in which the intent is to read the type constant associated with the function- or class-level generic that is bounded by an abstract class or interface.
As an example, consider the following definition:
interface MeasuredBox extends Box {
abstract const type TQuantity;
public function getQuantity(): this::TQuantity;
}
Projections off a generic at the function level, such as
function weigh_bulk_unsafe<TBox as MeasuredBox>(TBox $box): float
where TBox::TQuantity = float {
return $box->getQuantity();
}
can and should be translated into:
function weigh_bulk(MeasuredBox with { type TQuantity = float } $box): float {
return $box->getQuantity();
}
Hack offers a means of conditionally enabling specific methods via
where-clauses. E.g.,
to define a method unloadByQuantity
that is only callable on
subclasses of Box
where TQuantity
is an integer (representing
boxes with quantity that is countable exactly), one could write:
class Warehouse<TBox as Box> {
public function unloadByCount(TBox $boxes): void
where TBox::TQuantity = int
{ /* … */ }
}
This can be translated to type refinements with a nuance:
class Warehouse<TBox as Box> {
public function unloadByCount(TBox $boxes): int
where TBox as Box with { type TQuantity = int }
{ /* … */ }
}
Migration note:
This is stricter than the original version with where-clauses because it is actually sound. Notably, the migrated method, which now uses type refinements, is uncallable from unmigrated methods that still use where-clauses.
function callee_that_now_errs<TBox as Box>(
Warehouse<TBox> $warehouse,
TBox $unknown_box,
T $contents,
): void where TBox::T = T {
$warehouse->unloadByCount($unknown_box); // ERROR
/* … */
}
Therefore, while migrating code with pattern TGeneric::TAbstractType
and where-clauses, you will need to migrate top-down in the callee
graph. This process may also reveal some unsafe usages of the previous
pattern, which is too permissive in theory and could allow reading
abstract type (and thus fail at run-time).
and Hack may chose to do so in the future, too.