Parsable Lambdas and Expression Trees
Tier 1 · Story 16Complete
Tyhp supports parsable lambdas — a feature that allows the compiler to capture the abstract syntax tree (AST) of an inline fn expression instead of just compiling it to a closure. When a function parameter is typed PropertyPath<callable(T): R> or Expression<callable(T): R>, passing an inline fn expression causes the compiler to emit a data structure describing the expression tree. The receiving function can then inspect the structure of the lambda at runtime — walking property accesses, method calls, operators, and constants — to translate the expression into another domain such as SQL queries, validation rules, or serialization mappings.
The types come from the tyhp/lambda package; see tyhp/lambda for the full API.
Note
This is the same concept as C#'s Expression<Func<T, TResult>>, which powers Entity Framework's LINQ-to-SQL. The parameter type drives the behavior: if a parameter is typed Expression<callable(T): R>, the compiler emits an expression tree; if typed callable or \Closure, the compiler emits a normal closure.
PropertyPath<callable(T): R> — Type-Safe Property References
PropertyPath<callable(T): R> is the simplest form of parsable lambda. It captures a chain of property accesses from an fn expression. When a parameter is typed PropertyPath<callable(T): R>, the compiler converts fn ($p) => $p->propA->propB into a PropertyPath object containing the chain ['propA', 'propB'] plus type information.
<?tyhp
use Tyhp\PropertyPath;
class User {
public function __construct(
public string $firstName,
public Address $address,
) {}
}
class Address {
public function __construct(
public string $city,
public string $state,
) {}
}
class ColumnBuilder<T> {
public function select(PropertyPath<callable(T): string> $prop): static {
echo $prop->getPropertyName(), ' | ', $prop->getPath(), ' | ', \implode(',', $prop->getSegments()), "\n";
return $this;
}
}
$columns = new ColumnBuilder<User>();
// Single property
$columns->select(fn ($u) => $u->firstName);
// Chained property access
$columns->select(fn ($u) => $u->address->city);
Output:
firstName | firstName | firstName
city | address.city | address,city
PropertyPath Members
PropertyPath extends Expression, so it has every Expression member listed below as well as the members here. The constructor takes $sourceType and $resultType as strings and passes them to the parent Expression, which stores the result type as $returnType.
readonly array<string> $path
The property names in the access chain, in order. For fn (User $u) => $u->address->city, this is ['address', 'city'].
getPropertyName(): string
Returns the final property name (last element of the path). For fn (User $u) => $u->address->city, returns 'city'.
getPath(): string
Returns the full dot-notation path string. For fn (User $u) => $u->address->city, returns 'address.city'.
getSegments(): array<string>
Returns the path as an array of property names.
getValue(mixed $source): __CallableReturnType<TCallableShape>
Reads the property chain from $source and returns the value. For a null-safe chain, a null link yields null.
PropertyPath Compiled PHP Output
When you pass an fn expression to a PropertyPath parameter, the compiler emits a \Tyhp\PropertyPath::on(...) chain with one step per property. backedBy(...) receives the original fn, so the path can be executed directly and its result type comes from the closure's return type.
<?tyhp
$columns->select(fn ($u) => $u->address->city);
<?php
$columns->select(\Tyhp\PropertyPath::on(\User::class)->address->city->backedBy(fn(\User $u): string => $u->address->city));
Nullable Property Chains
PropertyPath supports null-safe property access. Nullable chains are allowed, and the path records which links used ?->.
<?tyhp
class User {
public function __construct(
public ?Address $billing = null,
) {}
}
class ColumnBuilder<T> {
public function selectMaybe(PropertyPath<callable(T): ?string> $prop): static {
echo $prop->getPath(), "\n";
return $this;
}
}
$columns = new ColumnBuilder<User>();
// Nullable chain — the result type is ?string
$columns->selectMaybe(fn ($u) => $u->billing?->city);
Output:
The emitted chain has a nullSafe() step before each property read through ?->. The expression tree then contains a NullSafeAccessExpression node for that link rather than plain property access:
<?php
$columns->selectMaybe(\Tyhp\PropertyPath::on(\User::class)->billing->nullSafe()->city->backedBy(fn(\User $u): ?string => $u->billing?->city));
getValue() returns null when a null-safe link is null:
<?tyhp
function readMaybe(User $user, PropertyPath<callable(User): ?string> $path): void {
$value = $path->getValue($user);
echo $value ?? 'null', "\n";
}
readMaybe($ada, fn ($u) => $u->billing?->city); // Ada has no billing address
readMaybe($ada, fn ($u) => $u->address->city);
Output:
Expression<callable(T): R> — Full Expression Trees
PropertyPath<callable(T): R> extends Expression<callable(T): R>. Expression supports full expression trees: any supported inline fn body — not just property chains — can be captured as a tree of expression nodes. This enables runtime inspection of comparison operators, method calls, binary expressions, captured variables, and more.
Expression takes one type argument: the callable shape of the lambda. Expression<callable(User): string> is a single-parameter lambda taking User and returning string. Expression<callable(T, T): int> is a two-parameter lambda returning int. Zero-parameter lambdas are Expression<callable(): R>.
<?tyhp
use Tyhp\Expression;
class QueryBuilder<T> {
public function where(Expression<callable(T): bool> $predicate): static {
echo $predicate->body->nodeType, ' ', $predicate->returnType, "\n";
return $this;
}
public function select(Expression<callable(T): string> $selector): static {
echo $selector->body->nodeType, ' ', $selector->returnType, "\n";
return $this;
}
}
int $minAge = 18;
$query = new QueryBuilder<User>();
$query->where(fn ($u) => $u->age > $minAge && $u->isActive);
$query->where(fn ($u) => $u->getFullName() === 'Ada Lovelace');
$query->select(fn ($u) => $u->firstName);
The fn expressions are converted to Expression trees because the parameter types are Expression<callable(T): R>.
Output:
binary bool
binary bool
propertyAccess string
Expression Members
readonly ExpressionNode $body
The expression tree root node representing the fn body.
readonly array<ParameterExpression> $parameters
The lambda parameters as ParameterExpression nodes.
readonly \Closure<TCallableShape> $callable
The compiled closure for execution. Every Expression carries its original compiled closure so it can be executed directly.
readonly string $returnType
The resolved return type of the expression, as a type string in the \Tyhp\Type::parse() format. When the closure's PHP return type is the erased form of the body's type (array for array<App\Post>), the full type is kept.
Parameter and property nodes keep full declared types in their type string: a parameter written Table<Blog> $t has type App\Table<App\Blog>, a struct parameter has type struct App\Address, and $b->posts on a public array<Post> $posts property has type array<App\Post> (the declared type from \Tyhp\Type::fromProperty()). Parse any of them with \Tyhp\Type::parse().
mixed $lastResult
The last value produced by __invoke or PropertyPath::getValue.
__invoke(__CallableParametersRest<TCallableShape> ...$args): __CallableReturnType<TCallableShape>
Executes the expression by delegating to the stored callable. This makes an Expression callable via $expr($arg).
compile(): \Closure<TCallableShape>
Returns the stored compiled closure.
Expression Builders
Expression also offers static helpers for building predicates without an inline fn:
static of<T extends object>(__ClassName<T> $sourceType, string|array ...$segments): \Tyhp\PropertyPathBuilder<T>
Starts a fluent property-path builder for the property chain $segments of $sourceType.
and(?\Tyhp\Expression<TCallableShape> $other = null): \Tyhp\Expression
Combines this predicate with $other using &&. Without an argument, it starts a fluent continuation.
or(?\Tyhp\Expression<TCallableShape> $other = null): \Tyhp\Expression
Combines this predicate with $other using ||. Without an argument, it starts a fluent continuation.
static andAll(\Tyhp\Expression<callable(mixed): bool> $first, \Tyhp\Expression<callable(mixed): bool> $second, \Tyhp\Expression<callable(mixed): bool> ...$rest): \Tyhp\Expression<callable(mixed): bool>
Combines two or more predicates with &&.
static orAll(\Tyhp\Expression<callable(mixed): bool> $first, \Tyhp\Expression<callable(mixed): bool> $second, \Tyhp\Expression<callable(mixed): bool> ...$rest): \Tyhp\Expression<callable(mixed): bool>
Combines two or more predicates with ||.
andAll and orAll take Expression<callable(mixed): bool> arguments. See tyhp/lambda for how they combine predicates and for the complete member reference.
Expression Node Types
Expression trees are built from a hierarchy of node types. Every node extends ExpressionNode and carries a type string and a nodeType identifier. The type string is the node's resolved type; for a literal it is the literal's type (for example 'int', 'string', or '18').
ParameterExpression
Represents a lambda parameter ($x). Properties: string $name, string $paramType, int $index. nodeType: parameter.
PropertyAccessExpression
Represents a property access ($x->firstName). Properties: ExpressionNode $object, string $property. nodeType: propertyAccess.
NullSafeAccessExpression
Represents a null-safe property access ($x?->address). Properties: ExpressionNode $object, string $property. nodeType: nullSafeAccess.
MethodCallExpression
Represents a method call ($x->getFullName()). Properties: ExpressionNode $object, string $method, array<ExpressionNode> $arguments. nodeType: methodCall.
StaticMethodCallExpression
Represents a static method call (Str::lower(...)). Properties: string $class, string $method, array<ExpressionNode> $arguments. nodeType: staticMethodCall.
BinaryExpression
Represents a binary operation ($x->age > 18). Properties: ExpressionNode $left, string $operator, ExpressionNode $right. nodeType: binary.
UnaryExpression
Represents a unary operation (!$x->isDeleted). Properties: string $operator, ExpressionNode $operand, bool $isPrefix. nodeType: unary.
ConstantExpression
Represents a literal value (18, 'hello', true, null), a class constant (User::ADULT_AGE), or a captured variable from the enclosing scope. Properties: mixed $value. nodeType: constant.
TernaryExpression
Represents a ternary expression ($x->a ? $x->b : $x->c). For the short form $x->a ?: $x->c, $ifTrue is null. Properties: ExpressionNode $condition, ?ExpressionNode $ifTrue, ExpressionNode $ifFalse. nodeType: ternary.
CoalesceExpression
Represents a null coalescing expression ($x->nickname ?? $x->name). Properties: ExpressionNode $left, ExpressionNode $right. nodeType: coalesce.
ArrayAccessExpression
Represents an array access ($x->tags[0]). Properties: ExpressionNode $array, ExpressionNode $index. nodeType: arrayAccess.
CastExpression
Represents a type cast ((string) $x->value). Properties: string $targetType, ExpressionNode $operand. nodeType: cast.
NewExpression
Represents a new expression (new Money($x->amount)). Properties: string $class, array<ExpressionNode> $arguments. nodeType: new.
InstanceofExpression
Represents an instanceof or is check ($x->value is int, $x instanceof Address). Properties: ExpressionNode $operand, string $targetType. nodeType: instanceof.
NullSafeMethodCallExpression
Represents a null-safe method call ($x->address?->format()). Properties: ExpressionNode $object, string $method, array<ExpressionNode> $arguments. nodeType: nullSafeMethodCall.
ArrayExpression
Represents an array literal ([$x->id, 'name' => $x->name]). Properties: array<ArrayItem> $items. Each ArrayItem has ?ExpressionNode $key (null when the item takes the next integer key) and ExpressionNode $value. nodeType: array.
LambdaExpression
Represents a nested arrow fn (fn ($p) => $p->rating > 3). Properties: array<ParameterExpression> $parameters, ExpressionNode $body, string $returnType. Evaluating it gives a \Closure. nodeType: lambda.
A ParameterExpression refers to the nearest enclosing LambdaExpression that declares a parameter of that name; otherwise it refers to the outer lambda's argument at its index. When a ?-> link meets null, the rest of that chain of property reads, method calls, and array reads is skipped and the chain is null, as in PHP.
Expression Tree Compiled PHP Output
When you pass an fn expression to an Expression<callable(T): R> parameter, the compiler emits a fluent \Tyhp\Expr chain that builds the tree, starting from the parameter (\Tyhp\Expr::param(name, type)). Property reads are property steps, and method calls and operators are builder methods such as call(...), gt(...), and eq(...). backedBy(...) receives the original fn and returns the \Tyhp\Expression; the tree's return type comes from the closure's return type. Captured variables and literals are passed as builder arguments, so their values are read when the tree is built.
<?tyhp
int $minAge = 18;
$query->where(fn ($u) => $u->age > $minAge);
<?php
$minAge = 18;
$query->where(\Tyhp\Expr::param('u', \User::class)->age->gt($minAge)->backedBy(fn(\User $u): bool => $u->age > $minAge));
<?tyhp
$query->where(fn ($u) => $u->getFullName() === 'Ada Lovelace');
<?php
$query->where(\Tyhp\Expr::param('u', \User::class)->call('getFullName')->eq('Ada Lovelace')->backedBy(fn(\User $u): bool => $u->getFullName() === 'Ada Lovelace'));
Captured Variables
Variables from the enclosing scope that are referenced in the lambda body become ConstantExpression nodes in the expression tree. Their values are read when the expression tree is constructed, at runtime. This enables patterns where runtime values are embedded in the tree. A captured variable must be definitely assigned where the fn appears (TYHP4324).
<?tyhp
int $minAge = 18;
string $requiredRole = 'admin';
// $minAge and $requiredRole are captured as ConstantExpression nodes
$query->where(fn ($u) => $u->age > $minAge && $u->role == $requiredRole);
// At runtime, the expression tree contains:
// - BinaryExpression(&&)
// - BinaryExpression(>)
// - PropertyAccessExpression('age')
// - ConstantExpression(18) ← captured $minAge
// - BinaryExpression(==)
// - PropertyAccessExpression('role')
// - ConstantExpression('admin') ← captured $requiredRole
ExpressionVisitor
The ExpressionVisitor abstract class provides a visitor pattern for walking expression trees. Library authors extend ExpressionVisitor to build SQL translators, serialization mappers, validation rule generators, and other domain-specific interpreters. visit() returns mixed, so a translator that composes strings narrows each result before using it.
<?tyhp
use Tyhp\Expression\BinaryExpression;
use Tyhp\Expression\ConstantExpression;
use Tyhp\Expression\ExpressionNode;
use Tyhp\Expression\ExpressionVisitor;
use Tyhp\Expression\PropertyAccessExpression;
class SqlWhereVisitor extends ExpressionVisitor {
public function visitBinary(BinaryExpression $node): mixed {
string $left = $this->sql($node->left);
string $right = $this->sql($node->right);
return "{$left} {$node->operator} {$right}";
}
public function visitPropertyAccess(PropertyAccessExpression $node): mixed {
return $node->property;
}
public function visitConstant(ConstantExpression $node): mixed {
if ($node->value is string) {
return "'" . \addslashes($node->value) . "'";
}
return \var_export($node->value, true);
}
private function sql(ExpressionNode $node): string {
mixed $result = $this->visit($node);
return $result is string ? $result : '?';
}
}
class VisitQuery<T> {
public function where(Expression<callable(T): bool> $predicate): string {
$visitor = new SqlWhereVisitor();
$sql = $visitor->visit($predicate->body);
return $sql is string ? $sql : '';
}
}
int $minAge = 18;
echo (new VisitQuery<User>())->where(fn ($u) => $u->age > $minAge), "\n";
echo (new VisitQuery<User>())->where(fn ($u) => $u->firstName === 'Ada'), "\n";
Output:
age > 18
firstName === 'Ada'
Nested Lambdas
An arrow fn inside the body, such as a callback argument, becomes part of the tree as a LambdaExpression. Its parameter types come from the parameter it is passed to: a callable(Post): bool parameter, a PropertyPath<callable(Tag): string> parameter, or the callable member of Expression<callable(Post): bool>|(callable(Post): bool). The nested body follows the same rules as the outer body. It can read the outer parameters and captured variables, and a nested parameter hides an outer parameter with the same name.
<?tyhp
class Posts {
public function any(Expression<callable(Post): bool>|(callable(Post): bool) $test): bool { return false; }
public function sum(callable(Post): int $value): int { return 0; }
}
int $minRating = 3;
// MethodCallExpression('any') on PropertyAccessExpression('posts'), with one argument:
// - LambdaExpression(parameters: [$p], returnType: 'bool')
// - BinaryExpression(>)
// - PropertyAccessExpression('rating') on ParameterExpression('p')
// - ConstantExpression(3) ← captured $minRating
$query->where(fn ($b) => $b->posts->any(fn ($p) => $p->rating > $minRating));
$query->where(fn ($b) => $b->posts->sum(fn ($p) => $p->rating) > 10);
A nested fn cannot have by-reference, variadic, or default-valued parameters, and a function () {} closure is not allowed in a tree (TYHP4322).
Multi-Parameter Expressions
Expression trees support multi-parameter lambdas for comparison-style expressions. Write the callable shape on Expression: Expression<callable(T, T): int> for comparators.
<?tyhp
class SortableList<T> {
public function sortBy(Expression<callable(T, T): int> $comparator): static {
echo \count($comparator->parameters), ' ', $comparator->parameters[0]->name, ' ', $comparator->parameters[1]->name, "\n";
return $this;
}
}
(new SortableList<User>())->sortBy(fn ($a, $b) => $a->lastName <=> $b->lastName);
// The Expression tree has two ParameterExpression nodes
Output:
nameof Integration
The nameof compile-time function accepts a property-chain arrow function for lightweight, refactoring-safe property name extraction. nameof always returns the last segment of the chain. Use PropertyPath when the full chain is needed.
<?tyhp
string $col = nameof(fn (User $u) => $u->firstName); // 'firstName'
// Nested paths still yield the last segment:
string $city = nameof(fn (User $u) => $u->address->city); // 'city'
Expression Serialization
Expression trees can be serialized to JSON using ExpressionSerializer. This enables passing expression trees across API boundaries, such as client-side query building sent to a server. The JSON object has parameters, returnType, and body keys.
<?tyhp
use Tyhp\Expression;
use Tyhp\Expression\ExpressionSerializer;
Expression<callable(User): bool> $expr = fn ($u) => $u->age > 18;
string $json = ExpressionSerializer::toJson($expr);
echo $json, "\n";
// Structural equality ignores the compiled closures — two independently
// constructed trees with the same shape compare equal.
bool $same = ExpressionSerializer::equals($expr, $expr);
bool $alsoSame = $expr->equals($expr);
echo $same ? 'true' : 'false', ' ', $alsoSame ? 'true' : 'false', "\n";
Output:
{
"parameters": [
{
"name": "u",
"index": 0,
"type": "User"
}
],
"returnType": "bool",
"body": {
"nodeType": "binary",
"type": "bool",
"operator": ">",
"left": {
"nodeType": "propertyAccess",
"type": "int",
"property": "age",
"object": {
"nodeType": "parameter",
"type": "User",
"name": "u",
"index": 0
}
},
"right": {
"nodeType": "constant",
"type": "18",
"value": 18
}
}
}
true true
Type System Integration
Expression trees integrate with the type system as follows:
Expression<callable(T): R> is callable: $expr($userInstance) runs the stored closure. compile() returns \Closure<callable(T): R>.
- An
Expression can be passed to a PHP function parameter declared callable, such as \array_filter($items, $expr).
- An
Expression<callable(T): R> value can be passed to a callable(T): R parameter with the same shape. A \Closure parameter accepts any Expression-typed argument: the compiler passes its ->callable, whether the argument is a variable (annotated or inferred) or a call result.
- When a parameter is declared
Expression<callable(T): R> or PropertyPath<callable(T): R> and you pass an inline fn, a generic R is inferred from the fn body, for example function select<R>(Expression<callable(Person): R> $selector): R. For PropertyPath, R is the type of the last segment, and a ?-> chain makes it nullable: fn ($p) => $p->boss?->name gives ?string. The same holds for a union with the plain callable, such as PropertyPath<callable(Person): R>|(callable(Person): R).
PropertyPath<callable(T): R> is a subtype of Expression<callable(T): R>. Code accepting Expression also accepts PropertyPath.
- An
Expression value passed to an Expression parameter must have the same callable shape; otherwise the argument is rejected with TYHP4010.
- When passing a regular
fn expression to a callable or \Closure parameter (not Expression), the compiler emits a normal closure — no expression tree.
- The parameter type drives the behavior: only
Expression<> and PropertyPath<> parameters trigger expression tree generation.
Supported Expression Types
The following expressions are supported inside fn expressions that target Expression<callable(T): R> parameters:
- Property access:
$x->prop
- Null-safe property access:
$x?->prop
- Method calls:
$x->method(...)
- Null-safe method calls:
$x?->method(...)
- Static method calls:
Class::method(...)
- Binary operators:
- arithmetic:
+, -, *, /, %, **
- string concatenation:
.
- comparison:
==, !=, ===, !==, <, >, <=, >=, <=>
- logical:
&&, ||, and, or, xor
- bitwise and shift:
&, |, ^, <<, >>
- Unary operators:
!, -, +, ~
- Literals:
int, float, string literals without interpolation, true, false, null (in any case)
- Class constants:
Class::CONST, and Class::class
- Captured variables from the enclosing scope
- Array access:
$x->items[$i]
- Array literals, list and keyed, including nested arrays:
[$x->id, 'name' => $x->name]
- Nested arrow
fn expressions (see Nested Lambdas)
- Ternary and short ternary:
$x ? $y : $z, $x ?: $y
- Null coalescing:
$x ?? $y (a CoalesceExpression)
- Type casts:
(string) $x->value
new expressions: new Money($x->amount)
instanceof and is checks: $x->value is int, $x instanceof Address
- Parenthesized sub-expressions
Unsupported Expression Types
The following expressions produce TYHP4322 — "Expression trees do not support <kind> expressions; simplify the fn body" — when used inside a parsable fn expression. The kind appears in the message:
| Expression |
Kind in the message |
Function calls: \strlen($x->name), $callback($x) |
function call |
Assignments, compound assignments (+=, ??=), and destructuring |
assignment |
Increment and decrement: $x->age++, --$x->age |
increment |
String interpolation: "hi {$x->name}" |
string interpolation |
match expressions |
match |
throw expressions |
throw |
await expressions |
await |
yield expressions |
yield |
function () { ... } closures |
closure |
A nested fn with a & return: fn &($p) => ... |
by-reference fn |
Nested fn parameters that are by-reference, variadic, or have a default value |
by-reference parameter, variadic parameter, parameter default value |
clone |
clone |
include / require |
include |
|> pipe |
pipe |
new class { ... } |
anonymous class |
@ error suppression |
error suppression |
Dynamic member names: $x->{$name}, $x->$method() |
dynamic member |
A class held in a variable or expression: new $class(), $class::create(), $x::KIND, $x::class |
dynamic class |
Named call arguments: $x->pad(width: 3) |
named argument |
Argument unpacking: $x->add(...$items) |
argument unpacking |
Array spread: [...$x] |
array spread |
First-class callables: $x->name(...), strlen(...) |
first-class callable |
Shell commands: `ls` |
shell command |
isset($x->name) |
isset |
empty($x->name) |
empty |
nameof(...) |
nameof |
print |
print |
exit |
exit |
Any other construct that has no tree form is reported by name too, spelled in lower-case words.
A fn body is a single expression. A block body, such as fn ($x) => { return true; }, is a syntax error.
Library Author Guide: Type-Safe Query Builder
This example demonstrates how a library author consumes expression trees to build type-safe queries. The query builder accepts Expression parameters for conditions and PropertyPath parameters for column references.
<?tyhp
use Tyhp\Expression;
use Tyhp\PropertyPath;
class UserQuery<T> {
/** @var array<Expression<callable(T): bool>> */
private array $conditions = [];
private ?string $selectColumn = null;
private ?string $orderColumn = null;
public function where(Expression<callable(T): bool> $predicate): static {
$this->conditions[] = $predicate;
return $this;
}
public function select<R>(PropertyPath<callable(T): R> $selector): static {
$this->selectColumn = $selector->getPropertyName();
return $this;
}
public function orderBy<R>(PropertyPath<callable(T): R> $prop): static {
$this->orderColumn = $prop->getPropertyName();
return $this;
}
public function describe(): string {
return \count($this->conditions) . ' condition(s), select ' . ($this->selectColumn ?? '*') . ', order by ' . ($this->orderColumn ?? '-');
}
}
// User code — fully type-safe, refactoring-safe
int $minAge = 18;
$people = (new UserQuery<User>())
->where(fn ($u) => $u->age > $minAge && $u->isActive)
->select(fn ($u) => $u->firstName)
->orderBy(fn ($u) => $u->lastName);
echo $people->describe(), "\n";
Output:
1 condition(s), select firstName, order by lastName
Using Expression as a Callable
Every Expression carries its compiled closure, so it can be called directly. This allows code that receives an Expression to either inspect the tree or just execute it.
<?tyhp
use Tyhp\Expression;
use Tyhp\Expression\ExpressionNode;
function process(Expression<callable(User): bool> $predicate, User $user): void {
// Option 1: Inspect the expression tree
ExpressionNode $body = $predicate->body;
echo $body->nodeType, "\n";
// Option 2: Just call it (delegates to the stored closure)
bool $result = $predicate($user);
echo $result ? 'true' : 'false', "\n";
// Option 3: Get the raw closure
\Closure<callable(User): bool> $fn = $predicate->compile();
echo $fn($user) ? 'true' : 'false', "\n";
}
process(fn ($u) => $u->age >= 18, $ada);
Output:
Best Practices
Tip
Use PropertyPath<callable(T): R> for simple property references in ORM column mappings, validation, and serialization configuration. It is lightweight and covers the most common use case.
Tip
Use Expression<callable(T): R> when you need to inspect the full structure of a predicate — comparisons, boolean logic, method calls — such as building SQL WHERE clauses or DSL translators.
Tip
Extend ExpressionVisitor to build domain-specific translators rather than manually type-switching on expression node types.
Tip
Use nameof with a property-chain fn for refactoring-safe property name extraction instead of hardcoded strings.
Tip
Every Expression carries its compiled callable. Design your API to accept Expression<callable(T): R> even if you sometimes just want to execute it — the caller can pass an fn expression and the library can either inspect or call it.
Common Mistakes
Danger
Don't use parsable lambdas for regular callbacks where you only need to execute the function. Expression trees have construction overhead — use callable or \Closure when you don't need to inspect the tree.
Danger
Don't pass non-fn arguments to PropertyPath parameters, or anything other than an inline fn or an existing Expression value to Expression parameters. Passing a variable that holds a plain closure produces TYHP4320 (for PropertyPath) or TYHP4323 (for Expression).
Danger
Don't use unsupported constructs (function calls, assignments, match, await, string interpolation) inside parsable fn expressions. Expression trees only support single-expression bodies made of the node types listed above.
Danger
Don't pass method calls or binary expressions to a PropertyPath parameter — PropertyPath only supports simple property access chains (fn ($x) => $x->a->b). Use Expression for more complex expressions.
<?tyhp
function findColumn(PropertyPath<callable(User): string> $prop): string {
return $prop->getPropertyName();
}
// OK — inline fn expression
findColumn(fn ($u) => $u->firstName);
// ERROR — not an inline fn expression
// $myFn = fn (User $u): string => $u->firstName;
// findColumn($myFn); // TYHP4320
// ERROR — not a property chain
// findColumn(fn ($u) => $u->getFullName()); // TYHP4321
// OK — use Expression for method calls
function findExpr(Expression<callable(User): string> $expr): string {
return $expr->body->nodeType;
}
echo findExpr(fn ($u) => $u->getFullName()); // methodCall
Compiler Errors
- TYHP4320: Parameter of type
PropertyPath<callable(T): R> requires an inline fn expression — reported when a non-fn argument, including an Expression value, is passed to a PropertyPath parameter.
- TYHP4321:
PropertyPath expression must be a simple property access chain — reported when the fn body contains method calls, operators, literals, array access, or other non-property-access expressions in a PropertyPath context.
- TYHP4322: Expression trees do not support
<kind> expressions — reported when an unsupported expression (see the table above) is used in an Expression fn body.
- TYHP4323: Parameter of type
Expression<callable(T): R> requires an inline fn expression — reported when an argument that is neither an inline fn nor an Expression value (a plain closure variable, a function closure, null) is passed to an Expression parameter.
- TYHP4324: Captured variable
$name in expression tree must be definitely assigned — reported when a captured variable may not be initialized where the fn appears.
- TYHP4010: Argument of type
Expression<callable(A): B> is not assignable to parameter of type Expression<callable(C): D> — reported when an Expression value of a different callable shape is passed.
- TYHP5024: Cannot emit an expression tree for this fn — reported by the emitter when an
Expression fn body contains a construct it cannot rebuild as a tree. The checker normally reports such bodies as TYHP4322; report TYHP5024 as a compiler bug.