Type Narrowing and Guards in Tyhp

Tier 0 · Story 08Complete

When dealing with values that can be of multiple types (such as union types or nullable types), you often need to verify or narrow the type before performing type-specific operations. Tyhp uses type guards -- boolean checks that narrow a variable's type within a guarded scope. This happens automatically: whenever a variable passes a type guard check, the compiler tracks its narrowed type within the guarded block. No explicit casts or re-declarations are needed.

How Type Narrowing Works

Type narrowing is scope-sensitive. When a variable passes a type guard check in a conditional, its type is narrowed within the guarded block. Outside the block, the variable reverts to its full declared type. Narrowing is automatic -- the compiler tracks the narrowed type and uses it for all subsequent type checks within the scope.

<?tyhp

string|int|float|bool $myVar = \getResultFromDatabaseQuery();

if (\is_string($myVar)) {
    // $myVar is automatically narrowed to `string` in this block
    $myVar = \substr($myVar, 0, 5);  // OK: substr expects string

    // Reassignment within a narrowed scope ends the current narrowing
    $myVar = 44;
    // $myVar is now narrowed to `int` for the rest of this block
}
// Outside the if block, $myVar is its full union type again:
// string|int|float|bool

Built-in Type Guards

PHP's built-in type checking functions act as type guards in Tyhp because their php-package tyhpdefs declare type-guard returns. When used in a conditional check, they narrow the type of the checked variable.

  • \is_string($val) -- narrows to string
  • \is_int($val) -- narrows to int
  • \is_float($val) -- narrows to float
  • \is_bool($val) -- narrows to bool
  • \is_array($val) -- narrows to array
  • \is_object($val) -- narrows to object
  • \is_null($val) -- narrows to null
  • \is_numeric($val) -- narrows to int|float|string
  • \is_callable($val) -- narrows to callable
  • \is_resource($val) -- narrows to resource

The instanceof and is Keywords

Tyhp supports instanceof from PHP and is as a Tyhp alias. They are equivalent type-guard expressions:

  • instanceof -- the standard PHP keyword
  • is -- Tyhp alias

Both check if a value is an instance of a specific class or interface (or a descendant of that type) and narrow the variable's type accordingly. is is the T_TYHP_IS token and is only available in Tyhp mode. Emit follows #[\Tyhp\NativeTypeTest] on a type-guard function or concrete static method: $x is string becomes \is_string($x) when is_string is marked that way. Unmarked class / interface / enum / trait names still emit native $x instanceof Fqn. Unions, nullables, structs, object-shape aliases, generic parameters, and other unmarked types emit \Tyhp\Type::is. $x is null is allowed and uses \is_null($x) when that function is marked. \is_a(...) is PHP's function; it is not the is operator.

When the right-hand side is an object-shape alias, $x is ClockShape / $x instanceof ClockShape is a shape guard. After a pass, $x is narrowed to that shape. The check is existence-only at runtime (public methods and properties listed on the shape). $x is ClockShape does not make $x->__construct() legal. $x is User when User is a real class still emits native instanceof.

<?tyhp

function fromObject(object $obj): void {
    if ($obj is ClockShape) {
        formatNow($obj);
    }
}
<?tyhp

BuilderInterface|ConnectionInterface|null $builder = \getRemoteBuilderInstance();

// Null check as type guard
if (!\is_null($builder)) {
    // $builder is narrowed to BuilderInterface|ConnectionInterface
    $isConnected = $builder->connected();
}

// instanceof narrows to specific type
if ($builder instanceof BuilderInterface) {
    // $builder is narrowed to BuilderInterface
    $builder->build();
}

// 'is' keyword works the same as instanceof for objects
if ($builder is ConnectionInterface) {
    // $builder is narrowed to ConnectionInterface
    $builder->disconnect();
}

// Scalars use `is` too — emit is `\is_string($value)` / `\is_int($value)` when those
// tyhpdef functions carry `#[\Tyhp\NativeTypeTest]`
mixed $value = \getMixedValue();
if ($value is int) {
    // $value is narrowed to int
}

Null Checks as Type Guards

Null comparisons also act as type guards. Checking !== null, === null, $x is null, or !\is_null() narrows nullable types:

<?tyhp

?string $name = \getOptionalName();

if ($name !== null) {
    // $name is narrowed to `string` (null removed from the union)
    echo \strtoupper($name);
} else {
    // $name is narrowed to `null` in the else branch
    echo "No name provided";
}

Negated Guards (Else Branches)

When a type guard check fails (the else branch), the variable is narrowed to the remaining types -- the original type minus the guarded type. This is called negative narrowing.

<?tyhp

string|int|null $value = \getData();

if (\is_string($value)) {
    // $value is `string`
} else {
    // $value is `int|null` (string was removed)

    if ($value !== null) {
        // $value is `int` (null was also removed)
    }
}

Narrowing Through Logical Operators

Type narrowing compounds through && (AND) and distributes through || (OR) operators. Later operands are type-checked the way PHP short-circuits: after a true && left operand, and after a false || left operand.

<?tyhp

string|int|null $val = \getData();
string $name = \getClassName();
\__ClassName $base = \getBaseClassName();

// AND narrows cumulatively
if ($val !== null && \is_string($val)) {
    // $val is `string` (both conditions apply)
}

// OR produces the union of the narrowed types
if (\is_string($val) || \is_int($val)) {
    // $val is `string|int`
}

// A failed `||` operand's negation applies to later operands in the same condition
if (!\class_exists($name) || !\is_subclass_of($name, $base)) {
    // `\is_subclass_of` sees `$name` as `__ClassName` because `class_exists` was true
}

Custom Type Guard Functions

You can define your own type guard functions using a special return type syntax. Instead of returning bool, the return type declares which subject is narrowed to which type using $paramName is TypeExpr, $paramName instanceof TypeExpr, or an index read $array[$key] is TypeExpr.

<?tyhp

// Custom type guard function
function isNonEmptyArray(mixed $value): $value is array {
    return \is_array($value) && \count($value) > 0;
}

bool|array $val = \myFunc(34);

if (isNonEmptyArray($val)) {
    // $val is narrowed to `array` in this block
    $first = $val[0];
} else {
    // $val is narrowed to `bool` in this block
    // (array was removed from the union)
}

The guard return type syntax is defined in the grammar's returnTypeGrammarAddon rule. You can use any of the is/instanceof variants in the return type declaration. The subject may be a parameter or one index read of a parameter ($array[$key], $array[0], $array['k']). At a call site, those parameter names are substituted for the arguments, so hasStringAt($k, $arr) narrows $arr[$k].

<?tyhp

// Guard with a class type
function isActiveUser(object $obj): $obj instanceof ActiveUser {
    return $obj instanceof ActiveUser && $obj->isActive();
}

// Guard with a union type
function isStringOrInt(mixed $val): $val is string|int {
    return \is_string($val) || \is_int($val);
}

// Guard narrowing to a scalar
function isPositiveInt(mixed $val): $val is int {
    return \is_int($val) && $val > 0;
}

// Guard that narrows an index read, not the array itself
function hasStringAt(int $key, array $array): $array[$key] is string {
    return \array_key_exists($key, $array) && \is_string($array[$key]);
}

function demo(array $arr, int $k): void {
    if (hasStringAt(0, $arr)) {
        string $first = $arr[0];
    }
    if (hasStringAt($k, $arr)) {
        string $value = $arr[$k];
    }
}

Dynamic Guard Functions

PHP's symbol existence functions also act as type guards, narrowing string values to Tyhp's symbol name types. This enables type-safe dynamic programming:

  • \function_exists($name) -- narrows $name to __FunctionName
  • \class_exists($name) -- narrows $name to __ClassName<object> (omit <T>; use \class_exists<Foo>($name) for __ClassName<Foo>, or \class_exists<ClockShape>($name) / \class_exists<__New<ClockShape>>($name) for a shape brand)
  • \interface_exists($name) -- narrows $name to __InterfaceName
  • \trait_exists($name) -- narrows $name to __TraitName
  • \enum_exists($name) -- narrows $name to __EnumName
  • \property_exists($obj, $name) -- narrows $name to __PropertyName<typeof($obj)>
  • \method_exists($obj, $name) -- narrows $name to __MethodName<typeof($obj)>
  • variable_exists($count) / variable_exists('count') -- narrows the name to __VarName

Early Return Narrowing

When a type guard is used with an early return (or throw, continue, break), the type is narrowed for all subsequent code after the guard. This is a common pattern for eliminating null or invalid types at the top of a function.

<?tyhp

function processUser(?User $user): string {
    if ($user === null) {
        return "No user";
    }

    // $user is narrowed to `User` for all remaining code
    // because the null case was handled by the early return
    return $user->getName();
}

function handleValue(string|int|array $val): void {
    if (\is_array($val)) {
        throw new \InvalidArgumentException("Arrays not supported");
    }

    // $val is narrowed to `string|int` for all remaining code
    echo $val;
}

Ternary and Match Narrowing

Type narrowing also works within ternary expressions and match expressions:

<?tyhp

?string $name = \getOptionalName();

// Ternary narrowing: in the true branch, $name is `string`
string $display = $name !== null ? \strtoupper($name) : "Anonymous";

// Match narrowing
string|int|float $value = \getMixedValue();
string $result = match(true) {
    \is_string($value) => $value,           // narrowed to string
    \is_int($value)    => (string) $value,   // narrowed to int
    default            => \number_format($value, 2), // narrowed to float
};

Compiled PHP Output

Type narrowing is entirely a compile-time concept. The guard functions compile to normal PHP -- no runtime type tracking is added. The narrowing information only exists during the Tyhp compilation phase for type checking purposes.

<?tyhp

function describe(string|int $val): string {
    if (\is_string($val)) {
        return "String: " . $val;
    }
    return "Int: " . $val;
}

Compiles to:

<?php
declare(strict_types=1);

function describe(string|int $val): string {
    if (\is_string($val)) {
        return 'String: ' . $val;
    }
    return 'Int: ' . $val;
}

Custom type guard functions compile identically to regular boolean functions -- the guard return type syntax is erased:

<?tyhp

function isPositiveInt(mixed $val): $val is int {
    return \is_int($val) && $val > 0;
}

Compiles to:

<?php
declare(strict_types=1);

function isPositiveInt(mixed $val): bool {
    return \is_int($val) && $val > 0;
}

The is keyword compiles the same way as instanceof. When a free function or concrete static method marked #[\Tyhp\NativeTypeTest] tests for exact T (function is_string(mixed $value): $value is string), $x is T emits a call to that callable. Otherwise a resolved class, interface, enum, or trait emits native instanceof. Remaining forms (unions, ?T, structs, generic parameters, unmarked aliases) emit \Tyhp\Type::is:

<?tyhp

function checkBuilder(object $obj): void {
    if ($obj is BuilderInterface) {
        $obj->build();
    }
}

function checkInt(mixed $n): void {
    if ($n is int) {
        echo $n;
    }
}

Compiles to:

<?php
declare(strict_types=1);

function checkBuilder(object $obj): void {
    if ($obj instanceof BuilderInterface) {
        $obj->build();
    }
}

function checkInt(mixed $n): void {
    if (\is_int($n)) {
        echo $n;
    }
}

Best Practices

Tip

Use early return patterns with null checks to avoid deep nesting. This narrows the type for all remaining code and makes functions easier to read.

Tip

Create custom type guard functions for complex type checks that you repeat across your codebase. This centralizes the logic and gives each call site automatic narrowing.

Tip

Use is or instanceof to narrow to specific class or interface types. Both work identically -- choose whichever reads more naturally in your code.

Tip

Use specific types in function parameters and return types to minimize the need for narrowing. The more precise your types, the less narrowing you need.

Tip

Take advantage of negated guards (else branches). After checking \is_string(), the else branch automatically has string removed from the union.

Common Mistakes

Danger

Don't assume a variable's type without using a type guard. The compiler rejects operations that are not valid for all types in the union.

Danger

Don't use mixed without narrowing it first. mixed requires a type guard before any type-specific operation can be performed.

Danger

Don't rely on PHP's implicit type coercion. Tyhp enforces strict types -- passing an int where a string is expected is always an error, even if PHP would coerce it.

Danger

Don't assume narrowing persists after scope exit. Once you leave the guarded block (if/else/while), the variable reverts to its full declared type.

Danger

Don't forget that reassigning a variable within a narrowed scope resets the narrowing to the new assigned type.

Compiler Error Examples

<?tyhp

// ERROR: Cannot call string method on a union type without narrowing
string|int $value = \getValue();
// $length = \strlen($value);  // strlen expects string, not string|int

// Fix: narrow first
if (\is_string($value)) {
    $length = \strlen($value);  // OK: $value is narrowed to string
}

// ERROR: Property access on potentially null type
?User $user = \findUser(42);
// $name = $user->getName();  // $user might be null

// Fix: null guard
if ($user !== null) {
    $name = $user->getName();  // OK: $user is narrowed to User
}

// Or use nullsafe operator (does not narrow, returns nullable)
?string $name = $user?->getName();

Important Notes

  • Type narrowing is scope-sensitive -- it only applies within the guarded block.
  • Assigning a value of a different type within a narrowed scope ends the current narrowing and starts a new one based on the assigned type.
  • Early returns, throws, continues, and breaks narrow the type for the remaining code after them.
  • The narrowed type is usually one of the types in the original union — except a shape guard, which refines object / mixed (or a union containing them) to the object-shape alias.
  • Outside of the guarded scope, the variable reverts to its full declared type.
  • Multiple guards compound: after checking !== null and then is Foo, the type is Foo.
  • Custom type guard functions must actually return a boolean value at runtime -- the guard return type only tells the compiler about the narrowing effect.