Source: src/types/ — mod.rs, model.rs, signatures.rs, result.rs, schema.rs, traits.rs, fibers.rs, checker/mod.rs, checker/driver/, checker/builtin_interfaces.rs, checker/builtin_iterators.rs, checker/builtin_json.rs, checker/builtin_spl_exceptions.rs, checker/builtin_spl_classes.rs, checker/builtin_spl_classes/, checker/builtin_class_gate.rs, checker/builtin_stdclass.rs, checker/builtin_types/, checker/builtins/, checker/functions.rs, checker/functions/, checker/inference/, checker/mixed_storage_scan.rs, checker/binding_decision_ambiguity.rs, checker/stmt_check.rs, checker/stmt_check/, checker/type_compat.rs, checker/type_compat/, checker/schema/, checker/yield_validation/, warnings/
PHP is dynamically typed — variables can change type at runtime. But elephc compiles to native code where every value must have a known size and location. The type checker bridges this gap by inferring types at compile time.
Why type checking matters
The code generator needs to know types to emit correct assembly:
- An
Intlives in registerx0(8 bytes) - A
Floatlives in registerd0(8 bytes) - A
Stringlives inx1(pointer) +x2(length) = 16 bytes
If the code generator doesn’t know whether $x is an integer or a string, it doesn’t know which registers to use, how many bytes to allocate on the stack, or which comparison instruction to emit (cmp for integers vs fcmp for floats).
The type system
File: src/types/model.rs
elephc has a small type system:
pub enum PhpType {
Int,
Float,
Str,
Bool,
False, // literal `false` subtype; runtime representation identical to Bool
Void, // null
Never, // marks a function/method that never returns (always throws / exits / loops)
Iterable, // PHP `iterable` pseudo-type (array | Traversable), type-erased
Mixed, // runtime-boxed heterogeneous array / user mixed value
Array(Box<PhpType>), // e.g., Array(Int) = int[]
AssocArray { // e.g., AssocArray { key: Str, value: Int }
key: Box<PhpType>,
value: Box<PhpType>,
},
Buffer(Box<PhpType>),
Callable, // closures and function references
Object(String), // class instance, e.g., Object("Point") or Object("App\\Point")
Packed(String),
Pointer(Option<String>), // opaque ptr or typed ptr<Class>
Resource(Option<String>), // generic resource or typed resource such as resource<stream>
Union(Vec<PhpType>),
TaggedScalar, // codegen-internal inline nullable scalar: {payload, tag} pair
}
This is still much smaller than full PHP’s runtime type system, but it now includes user-written union and nullable annotations where the language subset supports them. Union(...) values are lowered to the same boxed runtime representation used by Mixed. TaggedScalar is never produced by the checker itself: codegen funnels construct it from int|null unions under the tagged null representation (the default; see --null-repr), storing the value as an inline two-word {payload, tag} pair instead of a heap-boxed cell. The distinction between Array (indexed) and AssocArray (key-value) is determined at compile time from the literal syntax ([1, 2] vs ["a" => 1]), and heterogeneous payloads in either representation widen to boxed Mixed elements.
False is the PHP literal false subtype used by false-sentinel declarations such as int|false (the shape many builtins like strpos return). Its runtime representation is identical to Bool, and bool accepts a False value. Union normalization keeps a distinct False member only when the declaration is false-only: a full bool member in the same union absorbs it.
Never is a return-position-only marker: a function annotated : never must always diverge (throw, call exit()/die(), or loop forever). The type checker rejects any reachable return value; from such a function, and the runtime size is zero because the value is never materialized. : never is rejected as a parameter or local-variable type — same restriction as : void.
Iterable represents PHP’s iterable pseudo-type (array | Traversable). It is treated as a type-erased 8-byte raw heap pointer at runtime — the checker accepts Array, AssocArray, Iterator objects, and IteratorAggregate objects for parameters declared iterable, and foreach over an iterable local types both $key and $value as Mixed. Direct operations on iterable values (foreach, echo, gettype(), var_dump(), ===, scalar casts, is_iterable()) dispatch through the __rt_heap_kind runtime helper. Indexed-array iterables use the value-type tag stored in the array header to box loop values as Mixed; associative iterables reuse the hash iterator payload tag; object-backed iterables branch through interface metadata and then use the Iterator method dispatch path.
Callable is used for anonymous functions (closures), arrow functions, and first-class callables. Closure and first-class callable values are stored as one 8-byte descriptor pointer. The descriptor carries the callable kind, native entry ABI slot, optional PHP-visible name, signature/environment/invocation metadata, and an optional generated invoker slot. Dynamic-call builtins reuse that metadata and generated wrapper cases to invoke runtime-selected user functions, supported builtin string callbacks, public static-method strings, callable arrays, and invokable objects.
Object(String) represents a class instance. The string carries the canonical class name after name resolution (for example "Point" or "App\\Point"). Objects are heap-allocated pointers (8 bytes on the stack).
Pointer(Option<String>) represents a raw 64-bit address. Pointer(None) is an opaque pointer, while Pointer(Some("Point")) is a pointer tagged with a checked pointee type. The tag affects static checking, but the runtime value is still just an address in x0.
Resource(Option<String>) represents PHP resource handles. Resource(None) is a generic resource, while Resource(Some("stream")) is the stream-handle shape used by successful fopen() calls and the STDIN / STDOUT / STDERR constants. Resource values are stored as one 8-byte native payload in codegen, but the type checker keeps them distinct from integers so stream built-ins can reject plain numeric descriptors.
How inference works
The type checker walks the AST top-down, maintaining a type environment — a HashMap<String, PhpType> that maps variable names to their types. It also tracks a constants map — a HashMap<String, PhpType> that records the type of each user-defined constant (declared via const or define()).
Assignments create types
$x = 42; // $x: Int (inferred from the literal)
$name = "Alice"; // $name: Str
$pi = 3.14; // $pi: Float
$ok = true; // $ok: Bool
$nothing = null; // $nothing: Void
The first assignment determines a variable’s type. After that, reassignment is only allowed to the same type (with some exceptions, below) — for a declared type (a typed local, a type-hinted parameter, a class property) this is enforced strictly in every mode. For an untyped local, an incompatible reassignment is instead a warning by default; see Local retyping and strict locals mode.
Type compatibility rules
| From | To | Allowed? |
|---|---|---|
Int | Int | Yes |
Int | Float | Yes (numeric types are interchangeable) |
Int | Bool | Yes (numeric/bool interchangeable) |
Int | Str | No for a declared type — compile error. For an untyped LOCAL the default is a warning plus a fresh re-binding, and --strict-locals makes it the error (see Local retyping and strict locals mode) |
False | Bool | Yes (literal false is a subtype of bool) |
Void | anything | Yes (null can become any type) |
| anything | Void | Yes (any variable can become null) |
Array(T) | Array(U) | Yes, if T and U merge; heterogeneous indexed values widen to Array(Mixed) |
AssocArray(_, T) | AssocArray(_, U) | Yes, if T and U merge; heterogeneous values widen to Mixed |
Pointer(None) | Pointer(Some("T")) | Yes (merged to the more specific pointer tag) |
Pointer(Some("A")) | Pointer(Some("B")) | Yes, but merged to opaque Pointer(None) if tags differ |
Pointer(*) | Int / Str / Array | No — compile error |
Resource(None) | Resource(Some("stream")) (or vice versa) | Yes (generic resource accepts typed resources) |
Resource(Some("stream")) | Int | No — stream handles are not plain numeric descriptors |
Array(_) / AssocArray(_, _) / object implementing Iterator or IteratorAggregate | Iterable parameter | Yes (PHP iterable accepts arrays and Traversable objects at the call boundary) |
Declared boundaries are looser than plain reassignment. A Mixed value is accepted where a declared parameter, return, or property expects a plain Int, Float, Bool, or Str (PHP’s coercive mode): the checker lets it through and codegen inserts the runtime unboxing/narrowing conversion. Union values are accepted member-wise — every member of the actual union must be accepted by some member of the expected type.
Local retyping and strict locals mode
Files: checker/mod.rs (local_binding_is_killable, per-body eligibility state), checker/stmt_check/assignments/locals.rs (merge_local_assignment_type), checker/mixed_storage_scan.rs (run_mixed_storage_scan), checker/inference/expr/effects.rs (the unset() kill), checker/binding_decision_ambiguity.rs (span-collision guard)
A declared type — a typed local (int $x = 5;), a type-hinted parameter, or a class property — always enforces the type-compatibility table above strictly, in every mode: retyping one is a compile error.
For an untyped local, DEFAULT (permissive) mode instead allows a local to change type in three shapes that would otherwise be this same error. All three exclude a name that is reference-aliased (no =& target or source, no use (&$x) capture, no by-reference parameter including a variadic &...$xs tail, and neither name a by-reference foreach touches — foreach ($arr as &$v) permanently aliases BOTH $arr, the container the loop holds references into, and $v, which is itself one of those references, since PHP leaves it bound to the last element after the loop and lowering ref-binds its slot for the rest of the body), and a name this body binds with global, a static name, or a superglobal/seeded name, since this body does not own their storage.
Call arguments are the fourth aliasing source, and it is wider than a declared &$p. Where the checker resolves the callee’s signature it records an alias per parameter, at the by-ref slots alone (Checker::record_reference_alias_root). Where it CANNOT — a variable function, a signatureless callable, a dynamically named method, a runtime-selected call_user_func target, a pipe target that resolves to no name — Checker::record_unresolved_callee_argument_aliases records EVERY argument as aliased, by value included: an unknown callee may bind one by reference, and that reference outlives the call, so ending or re-binding the local would abandon storage the callee still points into. Losing eligibility costs nothing but the feature — the kill degrades to the pre-feature null store, the retype to the pre-feature error.
Shapes 1 and 2 — the ones that END a binding — are additionally gated by Checker::local_binding_is_killable: the local’s CURRENT binding must have been created by a statement that itself sits at conditional depth 0 (not inside an if/loop/try/switch/…) — which can be anywhere in the body, not only its first statement — and the unset/reassignment must itself sit at conditional depth 0 too, so the store that replaces the binding definitely runs.
require_once’d top-level code never satisfies that: its include guard lowers to a runtime branch, so every TOP-LEVEL statement of the included file sits at depth ≥ 1. That covers the included file’s TOP-LEVEL statements ONLY: enter_local_binding_scope resets the depth counter per body, so a function/method/closure declared in the same file starts its own locals at depth 0 and both shapes apply to them as usual. Plain require splices the file in unguarded, so even its top-level statements behave like inline code.
Shape 1 has one further top-level exclusion of its own, Checker::program_global_names: inside main, a name declared global by any function-like STATEMENT body of the program lives in a _eir_global_* symbol other bodies reach by name, so unset keeps the binding there and is a plain typing no-op. That set is crate::global_decls::collect_global_var_names, the ONE walk EIR lowering reads as well, and it descends into statement bodies only: a global written inside a closure body, an assignment prelude, or an enum method is invisible to the veto and to lowering alike — a deliberate blind spot shared by both sides, tracked upstream, with both directions of widening it measured and rejected (see the module preamble for the measurements). Shape 2 is deliberately NOT gated by that list — the retype through such a name lowers correctly today, and vetoing it would reject working code.
local_binding_is_killable also answers false for EVERY name in a body that calls eval() anywhere — the eval scope reaches caller locals by name, while ending a binding hands the name a different frame slot — which is why unset is a plain typing no-op in such a body and an incompatible reassignment there is the hard error in both modes. Shape 3 is gated by neither rule: it never ends a binding (see its own paragraph below), and requiring depth 0 would exclude its flagship if/else example, whose two assignments are both inside branches.
-
unset()kill.unset($a)on an eligible binding removes$afrom the type environment entirely. A later READ isUndefined variable: $a; a later assignment binds$afresh, at any type, with no warning. This kill is not gated bystrict_locals—unsetbehaves identically in both modes, because dropping a binding is always sound where the eligibility test holds. -
Straight-line retype. A plain statement-form reassignment (
$a = 0; $a = "ciao";) — including a compound form the parser desugars to a plain assignment, such as$x = 1; $x .= "a";— re-binds the name to a fresh binding of the new type and pushes this warning instead of failing:$a changes type from int to string; the previous value is discarded (compile with --strict-locals to make this an error)Only STATEMENT-form assignments are eligible; an expression-form assignment (
$b = ($a = "s");) keeps the hard error, because its result has no single well-defined type to hand the enclosing expression. -
Branch-divergent assignment (boxed
Mixedstorage).run_mixed_storage_scanruns a purely SYNTACTIC pre-scan of a body BEFORE it is type-checked, looking for a local whose statement-form assignments cannot all merge — the shape ofif (…) { $a = 0; } else { $a = "ciao"; }, a single-branch retype of an outer binding, or a heterogeneous loop-carried local. A marked name bindsPhpType::Mixedon EVERY assignment path, not just at its first store:merge_local_assignment_typeconsults the mark before it looks at the environment at all, so neither the retype hook nor the hard error ever fires for it. Re-asserting the mark at each store is what makes it dominate flow NARROWING — a guarded branch writes the narrowed type straight into the shared environment, and a first-store-only consult letif (…) { $a = 1; } else { $a = "s"; } if (is_int($a)) { $a = "z"; }reach the merge boundintand fail with the hard error in both modes. EIR lowering gives the name boxed frame storage for the whole body — every later read dispatches on the boxed value instead of using a plain register/stack slot. One warning is pushed per marked name:$a is assigned incompatible types (int and string); it is compiled as boxed mixed storage (compile with --strict-locals to make this an error)The pre-scan only trusts evidence it can type EXACTLY from an expression’s shape alone: a literal, a scalar cast (not
(array), whose element type is not knowable syntactically), or a.string concatenation (which always yieldsStrregardless of its operands). Any OTHER write to the name anywhere in the body — a plain variable or call-result value,++/--, aforeach/list()target, anunset()mention,=&,global, orstatic— disqualifies the whole name from marking, and the pair falls back to the hard error. An assignment inside a branch guarded by a NON-NEGATED type test on the name itself (if (is_string($a)) { $a = "x"; }) is skipped rather than counted: the guard established the type the assignment writes, so it is not evidence of divergence.PASSING the name to a call disqualifies it too, on a rule stricter than the one shapes 1 and 2 use, because this scan runs BEFORE the body is type-checked and holds nothing but
checker.fn_declsand the builtin registry — both keyed by NAME.callee_may_bind_arguments_by_refcan therefore only answer for aFunctionCall(and for theFirstClassCallable(Function(name))class ofPipetarget), and answerstruefor a name it fails to resolve. Every other call shape —MethodCall,NullsafeMethodCall,NullsafeDynamicMethodCall,StaticMethodCall,NewObject,NewScopedObject,NewDynamic,NewDynamicObject,ClosureCall,ExprCall— routes straight todisqualify_call_arguments, which disqualifies the root local behind EVERY argument, by-value ones included. So$c->m($a)with an ordinarym(mixed $v)costs a branch-divergent$aits mark and leaves it on the hard error, where the identicalf($a)keeps it. The asymmetry is deliberate: over-disqualifying only withholds a mark, while under-disqualifying would box a local a reference still reaches.A PARAMETER is never marked (it is already bound when the body starts, at a type the frame’s ABI fixed), but a by-value closure CAPTURE — the other pre-bound shape — is: suppressing its mark strands the value the capture owns. Its warning is withheld when replaying the name’s assignments from the capture’s INCOMING type merges cleanly, because the advice to compile with
--strict-localswould be false there; the mark and its boxed store sites stay either way.
--strict-locals turns shapes 2 and 3 back into the hard error:
Type error: cannot reassign $a from int to string
Shape 1 (unset() kill) is unaffected in either mode.
Because a Span carries no file identity, elephc cannot always tell apart two DIFFERENT statements that happen to sit at the same line and column — the same position in two different included files, or the same file required twice. When such a collision lands on a retype/kill/mixed-storage decision, binding_decision_ambiguity.rs rejects it with Cannot re-bind $a here: … instead of silently mis-lowering one of the two sites. It also checks the mixed-storage keys a later scan REMOVED (retired_mixed_storage_store_sites): a re-decision drops the decisions filed against the sites it is about to re-decide, matching by (span, name), so one body’s scan can strip another body’s decision and leave nothing behind to reject. The kill and retype maps are deliberately not given the same treatment — a stripped decision there falls back to the pre-feature lowering path, which is correct, whereas a stripped mixed-storage decision removes the name from CheckResult::mixed_storage_local_names() while the checker still types the local Mixed.
eval()’d code — whether interpreted by the optional Magician bridge or AOT-lowered from a literal fragment into an EIR scope function — reads and writes its locals through a boxed Mixed scope cell rather than a compiled, typed frame slot (see eval() scope behavior), so it was never subject to this rule to begin with: --strict-locals has no effect inside eval() fragments. The body that CALLS eval() is the reverse case: because the scope cell is addressed by NAME, shapes 1 and 2 would hand the fragment a slot the rest of the body no longer uses, so mixed_storage_scan records a per-body “contains eval” flag (Checker::body_contains_eval) and local_binding_is_killable refuses both there — body-scoped rather than point-in-time, since eval_barrier_active only rises once the walk has PASSED an eval and the hazard includes an eval below the site. See --strict-locals for the CLI flag.
Per-file strict_types
declare(strict_types=1) is scoped to one physical file, but the checker only ever sees the single flat program the resolver produces after include/autoload merging, and Span carries no file identity. The flag therefore rides on the AST: the parser records the directive on its per-file source profile (crate::source), Stmt::strict_types inherits it at construction alongside Stmt::source_mode, and every statement-rewriting pass re-installs the whole SourceProfile — which is why with_parse_mode/scoped_parse_mode take the profile rather than the mode alone.
Checker::check_stmt installs Stmt::strict_types on Checker::strict_types and restores the outer value afterwards, so the setting always reflects the file the call site was written in — matching PHP, where a strict file calling into a coercive one is strict and a coercive file calling a function declared in a strict one is not. Checker::require_strict_types_param_binding then runs before types_compatible, because the widenings PHP drops in strict mode (bool→int, int→bool, …) are ones types_compatible accepts on its own. Checker::with_internal_callback_binding suspends the flag while validating a callback that an internal function invokes (array_map, usort, …), which PHP calls from an engine frame that never carries the directive.
None of this is affected by the permissive local retyping described in Local retyping and strict locals mode above: a declared type stays strict regardless of strict_types or --strict-locals.
int $x = 42;
$x = "hello"; // ← Type error: cannot reassign $x from int to string (declared type, strict in every mode)
This is intentional — it lets the compiler know exactly what a declared $x is at every point, without needing runtime type tags.
Statement checks
Statement checking validates control-flow constraints that are not expression
types. foreach accepts indexed arrays, associative arrays, values typed
Iterable, and objects/interfaces that implement Iterator or
IteratorAggregate. Indexed and associative array loops bind key/value
variables to inferred element/key types; Iterable and object-backed iterator
loops bind them as Mixed because concrete payload tags are discovered at
runtime. break and continue track the active loop/switch target depth, so
break 2; is accepted only when two enclosing break/continue targets exist in
the current function or closure body. Function, method, and closure bodies reset
that depth so an inner declaration cannot target an outer loop. finally bodies
also record the target depth at entry: break or continue may target
loops/switches created inside that finally, but jumping out of a finally
block is rejected to match PHP.
Expression type inference
The type checker computes the type of every expression:
Literals
| Expression | Type |
|---|---|
42 | Int |
3.14 | Float |
"hello" | Str |
true / false | Bool |
null | Void |
[1, 2, 3] | Array(Int) |
[1, "two", true] | Array(Mixed) |
["a" => 1] | AssocArray { key: Str, value: Int } |
["a" => 1, "b" => "two"] | AssocArray { key: Str, value: Mixed } |
Binary operations
| Operation | Types | Result |
|---|---|---|
Int + Int | arithmetic | Int |
Float + Float | arithmetic | Float |
Int + Float | mixed arithmetic | Float |
Int / Int | division | Float (always — matches PHP) |
Int % Int | modulo | Int |
Str . Str | concatenation | Str |
Int . Str | concat with coercion | Str |
Int > Int | comparison | Bool |
Bool && Bool, Bool and Bool, Bool xor Bool | logical | Bool |
Int & Int | bitwise | Int |
Int <=> Int | spaceship | Int (-1, 0, or 1) |
expr instanceof ClassName | class/interface metadata check | Bool |
expr ?? expr | null coalescing | Type of the non-null operand |
print expr | output expression | Int (1) |
Function calls
Built-in functions have hardcoded type signatures (see below). User-defined functions, methods, constructors, closures, and arrow functions can carry declared parameter hints; functions, methods, closures, and arrow functions can carry declared return type hints. Declared non-void returns are validated both against returned values and against reachable fallthrough paths, while throw, exit()/die(), and provably infinite loops count as non-returning paths. Closure / arrow return annotations are represented in the AST and threaded into callable FunctionSig metadata; unannotated closures continue to infer their return type from the body or expression. Named arguments are validated against the declared parameter list before the usual argument-count and type checks run, including built-ins, extern calls, associative-array spreads with string keys, and spread prefixes that fill earlier positional slots. Variable AssocArray spreads before named arguments are treated as dynamic named providers, so required-parameter diagnostics are kept only when no preceding associative spread could provide the missing parameter at runtime. Unknown named arguments on user-defined variadic functions are accepted and typed as part of the variadic parameter, while built-in variadics reject unknown named arguments like PHP internal functions do. Positional spreads into variadic callees fill regular parameters first, then type the remaining tail as the variadic array.
Codegen receives enough signature information to evaluate named/spread arguments in PHP source order while still materializing values in ABI parameter order.
Eval barrier
eval() is recognized as a PHP language construct rather than a registry-backed
builtin. The checker requires exactly one argument, infers that argument for its
normal side effects, and assigns the call the static result type Mixed.
After an eval call, the caller-visible environment becomes dynamic: the
fragment may overwrite or unset existing locals, introduce new locals, declare
symbols, or mutate referenced storage. The checker therefore marks an eval
barrier, widens known local types to Mixed, discards callable/capture facts,
and permits later reads of variables and class-like symbols that may have been
created at runtime.
This rule is intentionally conservative for literal strings too. The later EIR planner may prove that a literal fragment can be AOT-lowered without runtime scope or interpreter state, but that backend decision must not hide frontend diagnostics or make pre-call type facts unsound. See Eval Runtime Architecture.
Built-in function signatures
Files: src/types/checker/builtins/, plus src/types/checker/mod.rs and src/types/checker/inference/ for special expression forms such as ExprKind::PtrCast, ExprKind::InstanceOf, and ExprKind::Print
Every built-in function has a registered type signature:
strlen($str: Str) → Int
substr($str: Str, $start: Int, $len?: Int) → Str
strpos($hay: Str, $needle: Str) → Int|False
array_search($needle, $arr: Array|AssocArray) → Int|Str|False
file_get_contents($filename: Str) → Str|False
fopen($filename: Str, $mode: Str) → resource<stream>|Bool
fileatime($filename: Str) / filectime($filename: Str) → Int|False
fileperms($filename: Str) / fileowner($filename: Str) / filegroup($filename: Str) / fileinode($filename: Str) → Int|False
filetype($filename: Str) → Str|False
stat($filename: Str) / lstat($filename: Str) / fstat($handle: resource<stream>) → AssocArray|Bool
define($name: Str, $value: scalar) → Bool
count($arr: Array|AssocArray) → Int
abs($val: Int|Float) → Int|Float
floor($val: Int|Float) → Float
rand($min?: Int, $max?: Int) → Int
ptr($var: lvalue) → Pointer(None)
ptr_get($ptr: Pointer) → Int
ptr_set($ptr: Pointer, $value: Int|Bool|Void|Pointer) → Void
ptr_cast<T>($ptr: Pointer) → Pointer(Some(T))
Most entries in the table above come from the builtin signature registry, while pointer-tag casts like ptr_cast<T>() are checked directly when the type checker visits ExprKind::PtrCast. instanceof is also checked as a dedicated expression: it always returns Bool, validates named self / parent / static targets against the current class context, checks dynamic target expressions for ordinary expression validity, and deliberately allows unknown named targets so runtime behavior can return false like PHP. For some built-ins the checker also enforces container shape, not just raw argument count:
array_push($arr, $val)requires the first argument to be an indexedArray, not anAssocArrayarray_column($rows, $column_key)requires the first argument to be an indexed array whose element type isAssocArraywordwrap()accepts 1 to 4 arguments, matching the builtin checker
The type checker validates:
- Argument count — too few or too many arguments → error
- Argument types — wrong types → error (in some cases; many builtins accept multiple types)
- Return type — used to infer the type of the call expression
Contextual callback parameter typing
File: src/types/checker/builtins/callables.rs
An array builtin types the unannotated parameters of its callback from the array it is given, so the idiomatic untyped closure/arrow function checks correctly:
$words = ["banana", "apple"];
usort($words, fn($a, $b) => strlen($a) <=> strlen($b)); // $a, $b are string
array_map (argument 0), array_all / array_any / array_filter / array_find /
array_reduce / array_walk / array_walk_recursive / uasort / uksort / usort
(argument 1) and array_udiff / array_uintersect (argument 2) are typed this way. Value
parameters take the array’s element type; key parameters take the array’s key type — Int for an
indexed array, the declared key type for an associative one. That is what uksort compares, what
array_filter passes under ARRAY_FILTER_USE_KEY / ARRAY_FILTER_USE_BOTH, and what
array_walk passes as its optional second callback parameter (added only when the callback
literally declares it, so a one-parameter callback still satisfies arity checking).
contextual_callback_arg_positions() is the single source of truth for those positions, and every
eager pre-inference pass consults it. Skipping them matters: inferring the closure before the hook
supplies its hints would check the body once against the unhinted parameter fallback and reject
valid code. Explicitly declared parameter types always stay authoritative, and an element type the
array does not pin down (Mixed/Never) leaves the parameter Mixed.
Null probes (isset / empty / unset / ??)
File: src/types/checker/null_probe.rs
isset(), empty(), unset() and the left operand of ?? / ??= exist to name storage that may
never have been declared, and PHP answers all of them without an Undefined variable warning. The
checker matches that: the spine of the operand’s access chain ($x, $x[...], $x->p, $x?->p)
may bottom out in an undeclared variable, which reads as null. Reaching through a null base is
allowed inside a probe too, so isset($never['k']) answers false instead of “Cannot index
non-array”. Index and property-name subexpressions are not covered — PHP still warns about $b
in isset($a[$b]), so that keeps the ordinary diagnostic, as does every read outside a probe.
Acceptance is decided at the end of the top-level pass rather than at the probe. EIR lowering
derives main’s local types from CheckResult::global_env, so a probed name is only representable
while it stays null for the whole scope: it must finish the pass unbound, and the checker then
seeds it as null so codegen answers from the slot type instead of reading storage no store ever
initializes. A name that is also assigned at top level (if (!isset($cfg)) { $cfg = 3; }) would
get that assigned type on a slot the probe reads before the store, so the original diagnostic is
restored for it.
User-defined function checking
Files: src/types/checker/functions.rs, src/types/checker/functions/
When the type checker encounters a function declaration, it:
- Collects all function declarations in a first pass (so functions can be called before they’re defined)
- Creates a local type environment for the function body (separate from global scope)
- Resolves declared parameter types when type hints are present, and otherwise falls back to the existing defaults / inference path
- Resolves the declared return type when present, and otherwise infers it from
returnexpressions - Validates defaults, call sites, and return statements against the declared types, including PHP-style default parameters such as
int $x = 10and named-argument reordering against the declared parameter names - Stores the
FunctionSig— parameter count, parameter types, return type, reference parameters, and variadic parameter
The FunctionSig struct (defined in src/types/signatures.rs):
pub struct FunctionSig {
pub params: Vec<(String, PhpType)>,
pub param_type_exprs: Vec<Option<TypeExpr>>, // source syntax of each parameter hint, when written
pub param_attributes: Vec<Vec<AttributeGroup>>, // PHP 8 attributes attached to each parameter
pub defaults: Vec<Option<Expr>>,
pub return_type: PhpType,
pub declared_return: bool, // whether return_type came from an explicit return hint
pub by_ref_return: bool, // function &f() — returns a reference to the returned lvalue
pub ref_params: Vec<bool>, // which parameters are pass-by-reference (&$param)
pub declared_params: Vec<bool>, // whether each parameter came from an explicit type hint
pub variadic: Option<String>, // variadic parameter name (...$args), if any
pub deprecation: Option<String>, // #[Deprecated] reason; "" when no reason was supplied
}
param_type_exprspreserves the exact sourceTypeExprof each written parameter hint, alongside the resolvedPhpTypeinparams.param_attributescarries PHP 8 attribute groups attached to each parameter, for Reflection metadata.by_ref_returnrecordsfunction &f()/fn &()declarations — the function returns a reference (alias) to the returned lvalue rather than a copy.ref_paramstracks which parameters use&(pass by reference). The codegen passes the stack address of the argument instead of its value.declared_paramslets later phases distinguish explicit PHP type hints from inferred/defaulted parameter types.declared_returnlets later phases distinguish explicit PHP return hints from inferred return types.variadicholds the name of the variadic parameter (e.g.,$argsinfunction foo(...$args)). Extra arguments beyond the regular parameters are collected into an array.deprecationcarries PHP 8.4#[Deprecated]metadata when present, so call sites can surface the warning consistently.
Call-site inference for untyped parameters
Parameters without a type hint start from an Int fallback and are specialized from the actual argument types observed at call sites. The first observed call discards the fallback exactly once and adopts that argument’s type, so an all-string (etc.) parameter is not polluted by unioning the fallback; the discard is remembered, so a genuinely later int call widens instead of re-adopting. When later call sites disagree, the parameter widens conservatively: a null argument combined with int under the default tagged null representation becomes the inline int|null union (a genuinely nullable scalar), two different object types keep the first object type so object-typed dispatch keeps working, and any other mix widens to Mixed, so those arguments are boxed at the call site and unboxed where they are used. Callable arguments never retype the parameter itself — the callable’s signature is recorded against the parameter name in callable_param_sigs instead. Under the legacy sentinel null representation (--null-repr=sentinel), a null argument never specializes a parameter.
The same accumulation applies to instance-method and static-method parameters. Closure parameters specialize to the first observed argument type but do not widen to a union, so a closure invoked with incompatible argument types is rejected rather than coerced.
This information is then used when checking calls to that function.
Type narrowing (is_* / instanceof / strict-comparison guards)
File: src/types/checker/stmt_check/narrowing.rs
Inside an if (or ternary) guarded by a type predicate, the checker narrows the guarded binding’s type for each branch. is_int/is_integer/is_long, is_float/is_double/is_real, is_string, and is_bool narrow to the corresponding scalar; $x instanceof Class narrows to that class; is_null($x) and the strict comparisons $x === null and $x === false (in either operand order) narrow to null and to the literal False subtype respectively. $x !== null / $x !== false and single-operand isset($x) are the same guards with the branches swapped, so a leading ! on them cancels out. The then-branch sees the guarded type and the else-branch sees the complement (a Union drops the matched members); a leading ! swaps the two. The false-sentinel case preserves the literal false: after if ($x === false) { throw ...; }, an int|false value continues as plain int, while a full bool member is not stripped.
The guarded receiver may be a variable, a simple instance property ($var->prop, $this->prop), or a simple static property (self::$p, Cls::$p). static::$p is never narrowed, because late static binding can select a subclass that redeclares the property. Property narrowings are stored under a synthetic environment key and are conservatively dropped after anything that could mutate the property — a property assignment, any call, or loop-body entry — and dropped per-object when the root local is rebound. Properties backed by PHP 8.4 get hooks or __get are never narrowed, because two reads may produce different values. Guard detection never raises a diagnostic of its own: a receiver whose type cannot be inferred is simply not narrowed.
Lazy initialization (the singleton shape)
A completed $this->p = <non-null> or self::$p = <non-null> write re-establishes the fact for that place, recorded as the property’s declared type minus null (never the assigned expression’s type — a declared property coerces what it stores). When a single guarded clause both falls through and wrote its guarded place, the type after the if is the union of the then-branch exit fact and the guard complement instead of the pre-if type. Together those make PHP’s lazy-initialization idiom check:
class S {
private static ?S $inst = null;
public static function get(): S {
if (self::$inst === null) { self::$inst = new S(); } // then-path: S
return self::$inst; // else-path: S => S
}
}
The !isset(self::$inst), self::$inst !== null early-return, self::$inst ??= new S() and $this->p ??= ... spellings all reach the same fact. An intervening call still drops it, so a genuinely unsound program (self::wipe(); return self::$inst; — a TypeError under PHP) stays rejected.
Return-type validation is flow-sensitive against these facts: each return records the type it had where it was checked (Checker::flow_typed_returns), so a narrowing established halfway down a body is not applied to a return that executes before it.
Narrowing applies across if/elseif/else chains: each subsequent clause (and the else) sees the accumulated complement of the previous guards. A chain with no else whose every clause body always diverges (return, throw, exit()/die(), or a call to a function declared : never) narrows the statements after the entire if construct to the accumulated complement. This is what makes the common “overload” shape type-check:
function set($x): void { // $x inferred int|Foo from the call sites
if (is_int($x)) { $this->n = $x; } // $x is int here -> stored into an int property
else { $this->o = $x->run(); } // $x is Foo here -> method dispatched on its class
}
Narrowing is purely a type-checker step: the variable keeps its boxed runtime (Mixed) representation, and codegen coerces it where the narrowed type is required — unboxing for scalar uses, and dispatching a method on a Mixed/union receiver by its runtime class id. Reassigning a narrowed variable inside a branch replaces the narrowed binding with the assigned type, and it invalidates any property narrowings rooted at that variable.
Diagnostics and warnings
The checker is no longer strictly first-error-only. Many passes now accumulate independent semantic errors and return them as a grouped diagnostic instead of aborting immediately on the first failure.
After successful checking, elephc also runs a warning pass over the AST (src/types/warnings/). Current warnings include:
- unused local variables and parameters
- unreachable code
- suspicious OOP declarations, such as
final privatemethods (never overridden, sofinalis meaningless outside__construct)
Warnings are returned through CheckResult and printed by the CLI without failing the compilation.
Where the checker sits in the optimizer pipeline
The type checker sits between an early folding pass and four post-check cleanup passes in src/optimize/:
fold_constants()runs first and simplifies scalar expressions that are already statically decidable.propagate_constants()runs after successful checking and pushes known scalar locals through conservative straight-line and merge shapes.prune_constant_control_flow()runs only after successful checking and warning collection, so dead branches can be removed without suppressing type errors or warnings that should still be reported.normalize_control_flow()runs after pruning and rewrites structurally equivalent control-flow shells into simpler AST shapes.eliminate_dead_code()runs after normalization and removes the leftover unreachable or non-observable statements.
That ordering is intentional. elephc is happy to rewrite 2 + 3 into 5 before checking, but it does not want an optimization pass to make broken code look valid by deleting it too early.
The global environment
Before checking user code, the type checker pre-populates the environment with built-in globals:
global_env.insert("argc", PhpType::Int);
global_env.insert("argv", PhpType::Array(Box::new(PhpType::Str)));
These correspond to PHP’s $argc and $argv superglobals.
Interface type checking
Before ClassInfo is built, the checker flattens trait composition through src/types/traits.rs, builds InterfaceInfo entries for every interface, and only then builds class metadata recursively.
pub struct InterfaceInfo {
pub interface_id: u64,
pub declaration_span: Span, // Span::dummy() for compiler-injected interfaces
pub parents: Vec<String>,
pub properties: HashMap<String, PropertyHookContract>,
pub property_order: Vec<String>,
pub method_decls: Vec<ClassMethod>, // source declarations retained for Reflection
pub methods: HashMap<String, FunctionSig>,
pub late_static_method_returns: HashMap<String, TypeExpr>, // exact syntax of `static`-typed returns
pub method_declaring_interfaces: HashMap<String, String>,
pub method_order: Vec<String>,
pub method_slots: HashMap<String, usize>,
pub static_methods: HashMap<String, FunctionSig>, // static interface methods (PHP 8.3+)
pub late_static_static_method_returns: HashMap<String, TypeExpr>,
pub static_method_declaring_interfaces: HashMap<String, String>,
pub static_method_order: Vec<String>,
pub constants: HashMap<String, Expr>, // interface constants (PHP 5.0+)
pub constant_types: HashMap<String, TypeExpr>, // PHP 8.3 typed constants
pub constant_declaring_interfaces: HashMap<String, String>,
pub final_constants: HashSet<String>, // PHP 8.1+ final constants
}
For each interface, the checker resolves interface extends interface transitively, rejects inheritance cycles, flattens required methods into a single signature map, and assigns a stable method ordering used by runtime metadata emission. properties records PHP 8.4 property hook contracts required by the interface, and constants carries interface constants inherited from parent interfaces, with PHP 8.3 declared constant types tracked in constant_types and PHP 8.1+ final constants in final_constants. static_methods records PHP 8.3+ static interface methods separately from instance methods: static dispatch is by class, so they take no vtable slot. Conformance checking requires a concrete implementing class to declare a compatible static method (Class {} must implement static interface method {}::{} otherwise); abstract classes may defer.
Interface methods declared to return PHP’s late-bound static type keep their exact return syntax in late_static_method_returns / late_static_static_method_returns. Return covariance is honored during conformance: an interface method whose required return names the interface itself (or via self/static) accepts an implementation that declares the implementing class’s own type, as long as the class implements the interface naming that return (directly or through a parent interface).
Class type checking
After interfaces are known, the checker builds each class so it sees parent-first property layout, inherited method signatures, abstract/final constraints, implemented interface contracts, and vtable slot assignments.
When the type checker encounters a ClassDecl, it:
- Registers the class in a
classes: HashMap<String, ClassInfo>map - Resolves the parent chain (
extends) and merges inherited metadata - Records each instance property with its type (declared when a property type is present, otherwise inferred from default values or constructor assignments) and a fixed offset in the inherited object layout
- Type-checks each method body with
$thisbound toObject(ClassName) - Builds
ClassInfocontaining instance and static property types, defaults, visibility maps, final property/method sets, signatures, declaring/implementation class maps, instance/static vtable slots, implemented interface lists, and constructor-to-property mappings
The ClassInfo struct:
pub struct ClassInfo {
pub class_id: u64,
pub declaration_span: Span, // Span::dummy() for compiler-injected classes
pub parent: Option<String>,
pub is_abstract: bool,
pub is_final: bool,
pub is_readonly_class: bool,
pub allow_dynamic_properties: bool, // #[\AllowDynamicProperties] (PHP 8.2)
pub constants: HashMap<String, Expr>, // user-declared class constants
pub constant_types: HashMap<String, TypeExpr>, // PHP 8.3 typed constants
pub constant_visibilities: HashMap<String, Visibility>,
pub final_constants: HashSet<String>, // PHP 8.1+ final constants
pub attribute_names: Vec<String>,
pub attribute_args: Vec<Option<Vec<AttrArgEntry>>>,
pub method_attribute_names: HashMap<String, Vec<String>>,
pub method_attribute_args: HashMap<String, Vec<Option<Vec<AttrArgEntry>>>>,
pub property_attribute_names: HashMap<String, Vec<String>>,
pub property_attribute_args: HashMap<String, Vec<Option<Vec<AttrArgEntry>>>>,
pub constant_attribute_names: HashMap<String, Vec<String>>,
pub constant_attribute_args: HashMap<String, Vec<Option<Vec<AttrArgEntry>>>>,
pub used_traits: Vec<String>,
pub trait_aliases: Vec<(String, String)>, // (alias, Trait::method)
pub properties: Vec<(String, PhpType)>,
pub property_offsets: HashMap<String, usize>,
pub property_declaring_classes: HashMap<String, String>,
pub defaults: Vec<Option<Expr>>,
pub property_visibilities: HashMap<String, Visibility>,
pub property_set_visibilities: HashMap<String, Visibility>, // PHP 8.4 asymmetric `set` visibility
pub declared_properties: HashSet<String>,
pub property_declared_slots: Vec<bool>, // per-layout-slot typed-declaration flags
pub final_properties: HashSet<String>,
pub readonly_properties: HashSet<String>,
pub reference_properties: HashSet<String>,
pub owned_reference_properties: HashSet<String>, // ref cells the object allocates/frees itself
pub promoted_properties: HashSet<String>,
pub property_reference_slots: Vec<bool>, // per-layout-slot by-reference flags
pub abstract_properties: HashSet<String>,
pub abstract_property_hooks: HashMap<String, PropertyHookContract>,
pub static_properties: Vec<(String, PhpType)>,
pub static_defaults: Vec<Option<Expr>>,
pub static_property_declaring_classes: HashMap<String, String>,
pub static_property_visibilities: HashMap<String, Visibility>,
pub declared_static_properties: HashSet<String>,
pub final_static_properties: HashSet<String>,
pub method_decls: Vec<ClassMethod>,
pub methods: HashMap<String, FunctionSig>,
pub static_methods: HashMap<String, FunctionSig>,
pub late_static_method_returns: HashMap<String, TypeExpr>, // exact syntax of `static`-typed returns
pub late_static_static_method_returns: HashMap<String, TypeExpr>,
pub callable_method_return_sigs: HashMap<String, FunctionSig>,
pub callable_array_method_return_sigs: HashMap<String, FunctionSig>,
pub method_visibilities: HashMap<String, Visibility>,
pub final_methods: HashSet<String>,
pub method_declaring_classes: HashMap<String, String>,
pub method_impl_classes: HashMap<String, String>,
pub vtable_methods: Vec<String>,
pub vtable_slots: HashMap<String, usize>,
pub static_method_visibilities: HashMap<String, Visibility>,
pub final_static_methods: HashSet<String>,
pub static_method_declaring_classes: HashMap<String, String>,
pub static_method_impl_classes: HashMap<String, String>,
pub static_vtable_methods: Vec<String>,
pub static_vtable_slots: HashMap<String, usize>,
pub interfaces: Vec<String>,
pub constructor_param_to_prop: Vec<Option<String>>,
}
vtable_methods / vtable_slots drive ordinary inherited instance dispatch, while static_vtable_methods / static_vtable_slots carry the parallel metadata used by static::method() late static binding. allow_dynamic_properties records the PHP 8.2 #[\AllowDynamicProperties] attribute so codegen can route undeclared property storage through a per-object side table. The *_attribute_names / *_attribute_args fields carry PHP 8 attribute metadata for the class, its methods, its properties, and its constants so the Reflection codegen path can materialize ReflectionAttribute objects. abstract_property_hooks records PHP 8.4 property hook contracts that concrete subclasses must satisfy, and property_set_visibilities records PHP 8.4 asymmetric write visibility (e.g. public private(set)) for properties whose write visibility differs from their read visibility. The per-slot vectors (property_declared_slots, property_reference_slots) follow the physical properties layout by index so hidden private parent slots keep their metadata when a child declares a same-named property.
Typed class constants (PHP 8.3)
src/types/checker/schema/class_constants.rs validates typed constant declarations on classes, interfaces, enums, and traits. Validation is deferred until all class-like schemas exist, so object and interface relationships named in constant types resolve. Declared types are recorded in constant_types; initializer values are checked strictly against the declared type apart from PHP’s allowed int-to-float widening, with a conservative Mixed inference accepted when an initializer cannot be narrowed statically. Inherited redeclarations must satisfy covariant type contracts, and constants declared final (PHP 8.1+) cannot be redeclared.
For abstract methods, the checker keeps the inherited signature but intentionally leaves the implementation-class entry unset until a concrete subclass provides a body. Concrete classes are rejected if any abstract or interface requirement remains unresolved after inheritance + trait flattening + interface conformance checks.
When checking property access ($obj->prop), the type checker validates that:
- The variable is an
Objecttype - The class has a property with that name
- The property is accessible (
public,protectedfrom the declaring class or a subclass, orprivateonly from the declaring class)
Nullsafe access ($obj?->prop, $obj?->method()) accepts object and nullable-object receivers. If the static receiver type is exactly null, the expression type is null without validating the skipped member. If the receiver is T|null, the checker validates the member against T and widens the result to include null.
Ordinary member access on a nullable union is also accepted when the union resolves to one concrete class. That keeps mixed chains such as $obj?->profile->address typeable: the null introduced by the earlier nullsafe segment remains in the inferred result, while codegen decides whether a later ordinary -> is skipped by the shared nullsafe branch or receives a real null and must warn/fatal like PHP.
When checking static property access (ClassName::$prop, self::$prop, parent::$prop, or static::$prop), the checker resolves the receiver to a class scope, verifies the static property exists, applies visibility rules against the declaring class, and enforces declared property types on assignment. Static property storage is keyed by the effective declaring class: inherited static properties share the parent slot until a subclass redeclares the property, at which point the subclass gets its own slot. Non-private redeclarations must keep invariant declared types, cannot reduce visibility, and cannot override final; private redeclarations are independent. Codegen handles late-bound static::$prop dispatch and reports a runtime fatal error if the called class resolves to a private redeclared slot outside the current method scope.
When checking property writes, explicitly declared property types stay fixed. Defaults, direct assignments, array writes, and constructor assignments routed through untyped parameters must be compatible with the declared property type. Nullable and union property types use the same boxed mixed runtime representation as typed locals, while untyped properties keep the existing inference and refinement behavior.
Constructor-promoted properties reach the checker as ordinary class properties plus synthetic constructor assignments produced by the parser. This lets promoted parameter type hints, defaults, visibility, readonly checks, and by-reference parameter validation reuse the same FunctionSig, property metadata, and constructor-to-property mapping used by handwritten constructor assignments. The checker records by-reference promoted properties in reference_properties, which codegen uses to store an alias pointer instead of an owned property value.
PHP 8.4 property hook contracts are represented as abstract property requirements on class metadata. Interface properties and abstract trait/class properties record separate get and set type obligations: get contracts are covariant, set contracts are contravariant, and get+set contracts are effectively invariant for ordinary properties. Concrete classes clear those abstract requirements when they redeclare a compatible instance property.
When checking method calls, it verifies the method exists, enforces method visibility (public, subclass-visible protected, declaring-class-only private), validates argument count and types against the method’s FunctionSig, resolves parent::method() against the immediate parent class, resolves self::method() against the current lexical class, and accepts static::method() as a late-static-bound static call against the current class hierarchy. First-class callable validation uses the same method metadata for static::method(...) and stable object receiver targets such as $obj->method(...).
Methods declared to return PHP’s late-bound static type (alone, nullable, or inside a union) keep their exact return syntax in late_static_method_returns / late_static_static_method_returns. At each call site the checker re-resolves that preserved syntax against the concrete receiver: $child->create() on a method inherited from a parent that returns static types as the child class, not the declaring class. The nominal FunctionSig return type stays the concrete declaring class or interface for compatibility checks.
When checking new ClassName(...), it also rejects interfaces and abstract classes before codegen.
Built-in coroutine and iterator classes
Throwable, Error, Exception, Fiber, and FiberError are registered as built-in class-like types before user code is checked. FiberError extends Error, matching PHP’s throwable hierarchy. Fiber method bodies are placeholders in ClassInfo: their signatures make calls type-checkable, while codegen intercepts construction, instance methods, Fiber::suspend(), and Fiber::getCurrent() and routes them to __rt_fiber_* helpers.
src/types/fibers.rs owns the additional static checks for Fiber callbacks. new Fiber(...) accepts closures, known callable variables, first-class callables, runtime string callbacks, static-method callable arrays, stored or literal instance-method callable arrays such as [$object, "method"], runtime-selected callable arrays such as [$object, $method], and invokable-object expressions such as new Job(). Literal string callbacks are resolved to user-function, builtin, extern, or public static-method signatures when possible; dynamic string variables defer descriptor selection to codegen. Stored instance-method callable-array variables are checked through the same first-class callable signature path, while codegen binds the receiver from the array slot itself. The visible callback parameter count is capped at seven start arguments, and by-reference callback start parameters are rejected when the signature is statically known. Closure captures and receiver environments live in callable descriptor capture slots rather than in Fiber’s visible start-argument area. Values moving through start(), resume(), suspend(), and getReturn() are typed as boxed mixed.
Iterator, IteratorAggregate, and the final built-in Generator class are injected by src/types/checker/builtin_iterators.rs. Generator implements Iterator and carries placeholder method bodies for current, key, next, valid, rewind, send, throw, and getReturn; codegen intercepts those methods and routes them to __rt_gen_* helpers. yield_validation marks any function or closure body containing yield as returning Object("Generator"), while still allowing declared return types compatible with Generator, Iterator, Traversable, or iterable.
Output: CheckResult
The type checker produces a CheckResult (defined in src/types/result.rs):
pub struct CheckResult {
pub global_env: TypeEnv, // variable name → type
pub functions: HashMap<String, FunctionSig>, // function name → signature
pub function_attribute_names: HashMap<String, Vec<String>>, // PHP 8 attributes on functions
pub function_attribute_args: HashMap<String, Vec<Option<Vec<AttrArgEntry>>>>,
pub callable_param_sigs: HashMap<(String, String), FunctionSig>, // (function, param) → callable signature
pub(crate) return_alias_summaries: ReturnAliasSummaries, // proven return-to-parameter storage aliases
pub callable_return_sigs: HashMap<String, FunctionSig>, // function → returned callable signature
pub callable_array_return_sigs: HashMap<String, FunctionSig>, // function → returned callable-array element signature
pub interfaces: HashMap<String, InterfaceInfo>, // interface name → interface info
pub classes: HashMap<String, ClassInfo>, // class name → class info
pub enums: HashMap<String, EnumInfo>,
pub packed_classes: HashMap<String, PackedClassInfo>,
pub extern_functions: HashMap<String, ExternFunctionSig>,
pub extern_classes: HashMap<String, ExternClassInfo>,
pub extern_globals: HashMap<String, PhpType>,
pub required_libraries: Vec<String>,
pub warnings: Vec<CompileWarning>,
pub throw_access_sites: HashMap<Span, ThrowAccessInfo>, // access violations lowered to runtime Error throws
}
This is passed to the code generator, which uses it to:
- Allocate the right amount of stack space per variable
- Choose the correct registers and instructions
- Emit proper type coercions
- Carry FFI declarations and linker requirements into codegen
Error examples
int $x = 42;
$x = "hello";
// Error: Type error: cannot reassign $x from int to string
// (a DECLARED type stays strict in every mode; an untyped local instead warns by
// default and only errors under --strict-locals — see "Local retyping and strict
// locals mode" above)
strlen(42);
// Error: strlen() argument must be string
unknown_func();
// Error: Undefined function: unknown_func
substr("hello");
// Error: substr() takes 2 or 3 arguments
Each error includes the exact line and column, thanks to the Span carried through from the lexer.