Narrowing a parameter type from instanceof
A canonical analyser pattern: the parameter has type T, and the body has an instanceof check. Inside the if, the type is narrowed to the intersection; in the else, the type is narrowed to the difference.
The PHP
function f(Foo|Bar|null $x): void {
if ($x instanceof Foo) {
// $x is Foo here
} else {
// $x is Bar|null here
}
}
The recipe
use suffete::{TypeId, lattice};
use suffete::world::World;
struct Branches { in_branch: TypeId, else_branch: TypeId }
fn narrow_instanceof<W: World>(
input: TypeId, // the parameter's type
target: TypeId, // the type from the instanceof's RHS, e.g. Foo
world: &W,
) -> Branches {
let opts = lattice::LatticeOptions::default();
let mut report = lattice::LatticeReport::new();
let in_branch = lattice::narrow(input, target, world, opts, &mut report);
let else_branch = lattice::subtract(input, target, world, opts, &mut report);
Branches { in_branch, else_branch }
}
The narrow for the positive case typically reduces to meet (input ⊓ target). The subtract for the negative case gives back the input minus the target.
Worked example
use suffete::{TypeBuilder, ElementId, prelude::NULL};
use suffete::world::NullWorld;
use mago_atom::atom;
let foo = ElementId::named_object(atom("Foo"));
let bar = ElementId::named_object(atom("Bar"));
let input = TypeBuilder::new().push(foo).push(bar).push(NULL).build();
let target = TypeBuilder::new().push(foo).build();
let world = NullWorld;
let branches = narrow_instanceof(input, target, &world);
// branches.in_branch == Foo
// branches.else_branch == Bar | null
When target is itself a union
if ($x instanceof Foo || $x instanceof Bar) {
// $x is Foo|Bar here
}
The analyser computes the union of the two instanceof targets and passes it as the target. The recipe is unchanged ; narrow and subtract handle multi-element targets.
When instanceof is on a generic class
function f(mixed $x): void {
if ($x instanceof Iterator) {
// $x is Iterator<mixed, mixed> here (the parameter's upper bounds)
}
}
The analyser constructs the target as Iterator<mixed, mixed> (or with whatever bounds the codebase declares). The narrowing handles this through the world's parameter declaration: each parameter is filled with its upper bound, and the from_template_default flag is set so the variance check is permissive.
let target = TypeBuilder::new().push(
ElementId::named_object_with_args(
atom("Iterator"),
&[suffete::prelude::TYPE_MIXED, suffete::prelude::TYPE_MIXED],
)
).build();
let branches = narrow_instanceof(input, target, &world);
// branches.in_branch == Iterator<mixed, mixed>
// (with the from_template_default flag set on the args)
When the input has no overlap with the target
function f(string $x): void {
if ($x instanceof Foo) {
// unreachable; $x is `never` here
}
}
let input = TypeBuilder::new().push(suffete::prelude::STRING).build();
let target = TypeBuilder::new().push(ElementId::named_object(atom("Foo"))).build();
let branches = narrow_instanceof(input, target, &world);
// branches.in_branch == TYPE_NEVER
// branches.else_branch == string
The analyser sees in_branch == TYPE_NEVER and can surface a diagnostic ("this branch is unreachable").
When the input fully refines the target
function f(Foo $x): void {
if ($x instanceof Foo) {
// always true; no narrowing needed
} else {
// unreachable; $x is `never` here
}
}
let input = TypeBuilder::new().push(foo).build();
let target = TypeBuilder::new().push(foo).build();
let branches = narrow_instanceof(input, target, &world);
// branches.in_branch == Foo (unchanged)
// branches.else_branch == TYPE_NEVER
The analyser can warn ("this instanceof is always true") or use the result silently.
Negation pattern
function f(Foo|Bar|null $x): void {
if (!($x instanceof Foo)) {
// $x is Bar|null here
} else {
// $x is Foo here
}
}
The analyser computes the same Branches and swaps which branch corresponds to which:
let branches = narrow_instanceof(input, target, &world);
// In the !instanceof branch (the if-body): branches.else_branch
// In the else branch: branches.in_branch
Combining with other narrowings
The analyser can chain narrowings as control flow proceeds:
function f(Foo|Bar|null $x): void {
if ($x === null) { return; } // narrow: subtract null
if ($x instanceof Foo) { ... } // narrow: meet with Foo
}
After the null check, the analyser narrows $x to Foo|Bar. Inside the instanceof Foo branch, narrows again to Foo. Each step is a standalone narrow or subtract call.
Performance
narrow and subtract are both lattice operations. Their cost is bounded by the input sizes; for typical analyser inputs (small unions, no fan-out), each call is sub-microsecond.
The analyser typically runs many narrowings during a single function body's analysis. Reuse the LatticeOptions and LatticeReport if possible; both are cheap to construct, but reusing avoids the Default cost in tight loops.
See also: narrow, subtract, meet for the underlying operations; predicates::is_never for detecting unreachable branches in the result.