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.
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.
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
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 resourceinstanceof and is KeywordsTyhp supports instanceof from PHP and is as a Tyhp alias. They are equivalent type-guard expressions:
instanceof -- the standard PHP keywordis -- Tyhp aliasBoth 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 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";
}
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)
}
}
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
}
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];
}
}
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 __VarNameWhen 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;
}
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
};
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;
}
}
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.
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.
Use is or instanceof to narrow to specific class or interface types. Both work identically -- choose whichever reads more naturally in your code.
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.
Take advantage of negated guards (else branches). After checking \is_string(), the else branch automatically has string removed from the union.
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.
Don't use mixed without narrowing it first. mixed requires a type guard before any type-specific operation can be performed.
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.
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.
Don't forget that reassigning a variable within a narrowed scope resets the narrowing to the new assigned type.
<?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();
object / mixed (or a union containing them) to the object-shape alias.!== null and then is Foo, the type is Foo.