Skip to content

Commit abbeb76

Browse files
committed
Update blog post on in-place typestates: refine content and replace images
1 parent cd52a21 commit abbeb76

3 files changed

Lines changed: 39 additions & 54 deletions

File tree

‎docs/blog/making-legal-states-impossible-in-place-typestates-in-pure-php.md‎

Lines changed: 39 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ These bugs exist because of a fundamental architectural blind spot we have polit
4747

4848
A closed `File` object will still cheerfully offer you a `read()` method. A `QueryBuilder` with zero fitlers will happily execute `delete()`. An idle database conection will smile and pretend it can `commit()`. The type system just sits there, sips its coffee, shrugs, and says: *"Sure, looks valid to me!"*
4949

50-
For nearly forty years, computer scientists have chased a holy grail designed to end this madness: **Typestate-Oriented Programming**, the radical, mind-bending proposition that an object's type should actually reflect its current state.
50+
For nearly forty years, computer scientists have pursued a holy grail designed to end this madness: **Typestate-Oriented Programming**, the radical, mind-bending proposition that an object's type should actually reflect its current state.
5151

5252
Mainstream languages looked at this idea, nodded thoughtfully in academic conferences, and then did everything in their power to avoid implementing it properly. C# freezes the type in carbonite. Rust destroys the value and runs away with it. Static analyzers wave their hands and pray CI catches it.
5353

@@ -153,23 +153,24 @@ Static analysis is great at catching typos before you commit. It is, by design,
153153

154154
We built a small typeatate machine that exercises three patterns that appear constantly in real production code:
155155

156-
1. Mutating `$this->state` inside transition methods.
156+
1. Using generic defaults (`@template TState of State = Unauthenticated`) so we never even have to write `@var` comments on instantiation!
157157
2. Passing the object through an alias (`$shared = $alice`), the classic shared-reference case that most PHP code relies on.
158158
3. Sending it through an untyped framework boundary (`untypedLayer(object $s)`).
159159

160160
The same code was analyzed by **PHPStan at Level MAX** and then executed under **TypePHP's live runtime engine**.
161161

162-
### What PHPStan Reports: 5 False Positives
162+
### What PHPStan Reports: 3 Errors (2 False Positives)
163163

164-
![PHPStan reporting errors on the typestate example](/blog/making-legal-states-impossible-in-place-typestates-in-pure-php/phpstan-typestate-errors.png)
164+
![PHPStan reporting 3 errors on the typestate example](/blog/making-legal-states-impossible-in-place-typestates-in-pure-php/phpstan-typestate-errors.png)
165165

166-
PHPStan surfaces a sea of red:
166+
PHPStan halted with **3 errors**:
167167

168-
* **Inside `login()` and `logout()`:** PHPStan refuses to accept the assignments to `$this->state`, treating the generic property as an immutable museum artifact. Even in massive codebases like Nextcloud core (`Response.php`), developers are forced to write `/** @psalm-suppress InvalidPropertyAssignmentValue */` just to shut the linter up!
169-
* **The Aliasing Blindspot:** After `$shared = $alice` and `$alice->login()`, PHPStan still insists `$shared` is unauthenticated. Flow analysis tracks variable *names*, not physical memory pointers, so the shared mutation is invisible to it.
170-
* **The Opaque Boundary:** Once `$bob` passes through `untypedLayer(object $s)`, the static model loses the transition and blindly rejects the later vault access.
168+
* **Line 55 (The Aliasing Blindspot):** PHPStan reported that `$shared` was unauthenticated, because static flow analysis tracks variable *names*, not physical heap pointers. It had no idea `$alice->login()` updated the shared instance in RAM.
169+
* **Line 71 (The Opaque Boundary):** Because `$bob` passed through `untypedLayer(object $s)`, PHPStan lost track of the transition and falsely claimed `$bob` could not access the vault.
170+
* **Line 78 (The Legitimate Catch):** PHPStan correctly caught `$s1` after it logged out.
171+
172+
Notice that out of 3 errors reported by PHPStan, **two were false positives on safe, legitimate code**, purely because static analysis cannot follow heap pointers.
171173

172-
None of these are bugs in PHPStan. They are the natural consequence of a tool that inspects text without ever executing it.
173174

174175
---
175176

@@ -180,9 +181,9 @@ None of these are bugs in PHPStan. They are the natural consequence of a tool th
180181
Now run the exact same code below inside TypePHP's live WebAssembly engine:
181182

182183
::: tip Try it yourself
183-
1. Click **Run** below. All three accesses succeed cleanly with `Access granted!`, followed by line 92 halting with an uncaught `TypeError` because `$s1` logged out!
184-
2. Click **Edit**, comment out line 90 (`$s1->logout();`), and run again. All four accesses now succeed with Exit 0!
185-
3. [Open the exact same code in the PHPStan Playground](https://phpstan.org/r/54a4bc39-e250-4f4d-b649-d48f3af7727f) to see the 5 static false positives side by side.
184+
1. Click **Run** below. All three accesses succeed cleanly with `Access granted!`, followed by line 78 halting with an uncaught `TypeError` because `$s1` logged out!
185+
2. Click **Edit**, comment out line 76 (`$s1->logout();`), and run again. All four accesses now succeed with Exit 0!
186+
3. [Open the exact same code in the PHPStan Playground](https://phpstan.org/try) to see the 3 static errors side by side.
186187
:::
187188

188189
<TypePHPSnippet title="Typestate Example: Static Analysis vs Runtime" default-view="source">
@@ -199,35 +200,19 @@ class Unauthenticated implements State {}
199200
class Authenticated implements State {}
200201
201202
/**
202-
* @template TState of State
203+
* @template TState of State = Unauthenticated
203204
*/
204205
class Session
205206
{
206-
/**
207-
* @var TState
208-
*/
209-
public object $state;
210-
211-
public function __construct()
212-
{
213-
$this->state = new Unauthenticated();
214-
}
215-
216207
/**
217208
* @phpstan-self-out self<Authenticated>
218209
*/
219-
public function login(): void
220-
{
221-
$this->state = new Authenticated();
222-
}
210+
public function login(): void {}
223211
224212
/**
225213
* @phpstan-self-out self<Unauthenticated>
226214
*/
227-
public function logout(): void
228-
{
229-
$this->state = new Unauthenticated();
230-
}
215+
public function logout(): void {}
231216
}
232217
233218
/**
@@ -241,25 +226,27 @@ function accessVault(Session $session): void
241226
}
242227
243228
// -------------------------------------------------------------
244-
// 1. Direct Transition (Succeeds)
229+
// 1. Direct Transition (Both PHPStan & TypePHP Pass)
245230
// -------------------------------------------------------------
231+
246232
$s1 = new Session();
247233
$s1->login();
248-
accessVault($s1);
234+
accessVault($s1); // ✓ Succeeded in both!
249235
250236
// -------------------------------------------------------------
251-
// 2. The Shared-Reference Case
237+
// 2. The Aliasing Test (PHPStan fails on line 55, TypePHP succeeds)
252238
// -------------------------------------------------------------
239+
253240
$alice = new Session();
254241
$shared = $alice; // Points to the EXACT same instance in RAM!
242+
255243
$alice->login();
256244
257-
// TypePHP knows $shared points to the same authenticated heap instance.
258-
// This matches the natural expectation when an object changes state.
259-
accessVault($shared);
245+
// PHPStan throws a false positive here because it tracks variable names:
246+
accessVault($shared); // ✓ TypePHP succeeds!
260247
261248
// -------------------------------------------------------------
262-
// 3. Opaque Framework Boundary
249+
// 3. Opaque Framework Boundary (PHPStan fails on line 71, TypePHP succeeds)
263250
// -------------------------------------------------------------
264251
function untypedLayer(object $s): void
265252
{
@@ -271,24 +258,24 @@ function untypedLayer(object $s): void
271258
$bob = new Session();
272259
untypedLayer($bob);
273260
274-
// At runtime, $bob physically logged in. TypePHP verifies its live state:
275-
accessVault($bob);
261+
// PHPStan throws a false positive here because $bob passed through `object`:
262+
accessVault($bob); // ✓ TypePHP succeeds!
276263
277264
// -------------------------------------------------------------
278-
// 4. Runtime Gatekeeping (Catches actual unauthenticated access)
265+
// 4. Runtime Gatekeeping (Both PHPStan and TypePHP Catch This)
279266
// -------------------------------------------------------------
280267
$s1->logout();
281268
echo "Attempting access after logout...\n";
282-
accessVault($s1);
269+
accessVault($s1); // Both agree: Throws TypeError on line 78!
283270
```
284271

285272
</TypePHPSnippet>
286273

287274
TypePHP succeeds here for three concrete reasons:
288275

289-
1. **Speculative Property Rollbacks:** When `$this->state = new Authenticated()` runs inside `login()`, TypePHP’s `InlineChecker::trySelfOutTransition()` speculatively tests the impending `@self-out` transition and allows the assignment without requiring ugly suppress comments.
276+
1. **Zero Boilerplate:** By declaring `@template TState of State = Unauthenticated`, `$s1 = new Session()` defaults cleanly to `Unauthenticated` without needing `@var` comments.
290277
2. **Heap Pointer Aliasing:** It keys state by `spl_object_id`. When `$alice->login()` runs, the physical instance updates, so `$shared` reflects the change immediately.
291-
3. **Observing the Actual Heap:** It observes what physically happened in memory, not what a static symbol graph guessed.
278+
3. **Observing the Actual Heap:** It observes what physically happened in memory, not what a static symbol graph guessed. Both tools agree on line 78, but only TypePHP lets lines 55 and 71 run as intended.
292279

293280
---
294281

@@ -361,20 +348,18 @@ Notice what this eliminates:
361348

362349
---
363350

364-
## Conclusion: PHP’s Accidental Advantage
365-
366-
For over twenty years, computer science purists have treated PHP’s lack of native C-level reified generics as proof that it is a toy language for building simple websites.
351+
## Conclusion: Different Languages, Different Trade-Offs
367352

368-
That missing C-level rigidity turned out to be an unexpected gift:
353+
It is easy to look at PHP's lack of native C-level reified generics and assume the language is behind compiled peers like C# or Rust. But language design is always an exercise in choosing the right trade-offs for a specific domain:
369354

370-
* **C# and Swift** welded generic metadata into the heap header and locked it in carbonite forever.
371-
* **Rust** chose affine ownership and forced you to destroy your data just to change its clothes.
372-
* **TypePHP** took advantage of PHP's dynamic userland, decoupled generic metadata into `\WeakMap`, and simply mutated it in place, preserving the shared-reference model that PHP developers already take for granted.
355+
* **C# and Swift** prioritized raw throughput and cache-friendly memory layouts by embedding generic metadata directly into physical object headers, making in-place type changes an intentional impossibility for safety and performance reasons.
356+
* **Rust** chose affine ownership to guarantee thread safety and memory management without a garbage collector, which naturally models state machines by moving and consuming values rather than mutating shared references. It is a brilliant design for systems programming.
357+
* **TypePHP** takes advantage of PHP's dynamic, garbage-collected object model to decouple generic metadata into an external `\WeakMap`, evolving the type tag in place while keeping the shared-reference model that PHP developers rely on every day.
373358

374-
The result is actual **in-place Typestate-Oriented Programming**: state machines enforced by structural type contracts, without breaking reference identity, running in 100% pure PHP.
359+
The result is practical **in-place Typestate-Oriented Programming**: state machines enforced by structural type contracts without breaking reference identity, all running in 100% pure PHP.
375360

376-
Sometimes the language everyone calls "dumb" is the only one flexible enough to do the smart thing.
361+
Different languages make different compromises for their runtimes. In PHP's case, its dynamic userland turned out to be the exact architectural ingredient needed to make in-place typestates feel right at home.
377362

378363
***
379364

380-
*Have a state machine or workflow you would like to model? Check the dedicated [Mutable Generics & Typestates Guide](/docs/generics/mutable-generics-and-typestates) or try it live in the [Interactive WebAssembly Playground](/playground).*
365+
*Have a state machine or workflow you would like to model? Check the dedicated [Mutable Generics & Typestates Guide](/docs/generics/mutable-generics-and-typestates) or try it live in the [Interactive WebAssembly Playground](/playground).*
-79.8 KB
Loading
-839 Bytes
Loading

0 commit comments

Comments
 (0)