DEV Community

Cover image for PHP 8.4 Unions Aren't Discriminated Unions. tsc Knows Louder.
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHP 8.4 Unions Aren't Discriminated Unions. tsc Knows Louder.


You wrote your first PHP 8.0 union type and felt the floor steady under you.

function notify(Email|Sms $msg): void { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

PHPStan, PhpStorm, and the reviewers were all happy. A year later you added a Push channel, made it a sibling class, updated the union to Email|Sms|Push, and the test suite went green. PHP 8.4 was, at the time, the most type-safe PHP that had ever existed (8.5 has since shipped with the pipe operator and friends, but the union story below is unchanged). You shipped. You moved on.

Then you joined a TypeScript codebase and tried to write the same thing.

function notify(msg: Email | Sms): void { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

The syntax is identical. The semantics are not. And the moment you add a third variant and forget one branch, tsc walks up to your desk and tells you about it. PHPStan, even at level 10, will not. Not because PHPStan is bad. Because the contract underneath those two | characters is not the same contract.

If you have read about discriminated unions and have not yet felt the click, the click is about what the compiler is allowed to assume after a narrowing branch returns.

Same code, two different floors

You are a careful PHP developer who writes strict types and runs PHPStan at the highest level the team is on. Today, that level can go up to 10. PHPStan 2.0 added it in November 2024, and it treats every mixed value strictly so nothing slips through unchecked (PHPStan rule levels).

You have this:

<?php
declare(strict_types=1);

final class Email { public function __construct(public string $to) {} }
final class Sms   { public function __construct(public string $phone) {} }

function send(Email|Sms $msg): string {
    if ($msg instanceof Email) {
        return "email to {$msg->to}";
    }
    return "sms to {$msg->phone}";
}
Enter fullscreen mode Exit fullscreen mode

Add a third channel:

final class Push { public function __construct(public string $deviceId) {} }
Enter fullscreen mode Exit fullscreen mode

Update the signature to Email|Sms|Push. Run PHPStan at level 10. It is happy. The function still type-checks. The else branch now silently handles Sms and Push together, and $msg->phone will fatal at runtime the first time a Push walks in, because Push does not have a phone property.

The TypeScript version, written the way most PHP-to-TS migrants write it on day one:

type Email = { to: string };
type Sms   = { phone: string };

function send(msg: Email | Sms): string {
  if ("to" in msg) return `email to ${msg.to}`;
  return `sms to ${msg.phone}`;
}
Enter fullscreen mode Exit fullscreen mode

This works. It even narrows. But it narrows by property presence, which is fragile: the moment every variant has the key you are checking, narrowing tells you nothing. The TypeScript idiom that PHP does not naturally have is the discriminated union: every variant carries a string-literal tag, and the compiler narrows on that tag.

type Email = { kind: "email"; to: string };
type Sms   = { kind: "sms";   phone: string };
type Msg   = Email | Sms;

function send(msg: Msg): string {
  switch (msg.kind) {
    case "email": return `email to ${msg.to}`;
    case "sms":   return `sms to ${msg.phone}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now add Push:

type Push = { kind: "push"; deviceId: string };
type Msg  = Email | Sms | Push;
Enter fullscreen mode Exit fullscreen mode

tsc immediately complains. The switch no longer covers every kind. The return paths do not produce a string for the push case, and TypeScript surfaces that as a compile error. PHPStan, looking at the same logical change in PHP, sees nothing to flag, because the else branch is still well-typed PHP. The bug is semantic, not syntactic.

What instanceof actually does in PHP

PHP's union types ship as part of the type system since PHP 8.0 (PHP RFC: Union Types 2.0). They are checked at the call boundary: pass a Logger to Email|Sms and PHP throws a TypeError. Inside the function, you narrow with instanceof, and PHPStan tracks that narrowing across branches (PHPStan: Narrowing Types).

The contract is: at this point in the code, the value is one of these classes. Narrowing happens by reflective class identity. PHP knows $msg instanceof Email because every object carries its class metadata at runtime, and PHPStan models that statically.

What PHP does not do, even in 8.4 (or 8.5):

  1. There is no language-level "this union is closed" guarantee. Marking a class final (as in the example above) blocks subclassing for that one class, but the union as a whole is still an open set: nothing stops a future maintainer from removing final, adding a sibling to the union, or passing a subclass through where the union is non-final. Your instanceof Email branch will catch any subclass too, which is usually what you want, but it means the union is not a sum type.
  2. There is no compiler that walks every branch and asks "did you return for every variant in the union?" The right tool to ask for adjacent help is phpstan/phpstan-strict-rules (with knobs like reportAlwaysTrueInLastCondition and checkAlwaysTrueInstanceof), and several community extensions try to bolt closed-set exhaustiveness on top. None of that is the language's default contract.
  3. There is no never floor type that the compiler will refuse to reach unless you have eliminated every other case.

You can simulate exhaustiveness in PHP. People do. The pattern usually looks like a match over get_class($msg) with a default that throws. That works at runtime. It does not give you a compile error when you add a variant, because PHP's compile step does not include type-system proofs the way tsc does.

What TypeScript narrowing actually does

TypeScript's union types look identical at the syntax level (A | B | C); the semantics are control-flow analysis. The compiler tracks, statement by statement, what type a name could be at any point. When it sees a narrowing predicate (typeof x === "string", x instanceof Foo, x.kind === "email", "to" in x, a user-defined type guard), it shrinks the union for the rest of that branch.

Two pieces are doing work that PHP does not have.

First, the discriminant. When every variant of a union has a property whose type is a literal ("email", "sms", 42, true), TypeScript can do exact narrowing on it. After case "email":, the compiler knows the value is precisely Email, not "could be Email". This is the discriminated-union pattern, and it is the idiom because it composes with the next piece.

Second, the never floor. never is the empty type: no value inhabits it. After a switch on the discriminant covers all variants, the implicit type at the bottom is never. If you add a new variant and forget to handle it, the bottom type becomes the new variant, and any code that demands never (a function that takes a never parameter, an assignment to a never variable) fails to type-check.

The pattern that nails this down is assertNever:

function assertNever(x: never): never {
  throw new Error(`unhandled variant: ${JSON.stringify(x)}`);
}

function send(msg: Msg): string {
  switch (msg.kind) {
    case "email": return `email to ${msg.to}`;
    case "sms":   return `sms to ${msg.phone}`;
    case "push":  return `push to ${msg.deviceId}`;
    default:      return assertNever(msg);
  }
}
Enter fullscreen mode Exit fullscreen mode

Drop the push case. tsc reports: Argument of type 'Push' is not assignable to parameter of type 'never'. The error points at the call site of assertNever, naming the variant you forgot. There is no way to ship without either handling it or explicitly casting the missing case away (TypeScript Handbook — Exhaustiveness checking).

You can also use satisfies never:

default: msg satisfies never; throw new Error("unreachable");
Enter fullscreen mode Exit fullscreen mode

Same semantics, slightly less ceremony. Both ride on the same engine: control-flow narrowing into never.

Side by side: adding a variant

Here is the full pair, the moment a third variant lands.

PHP 8.4, with PHPStan at level 10:

<?php
declare(strict_types=1);

final class Email { public function __construct(public string $to) {} }
final class Sms   { public function __construct(public string $phone) {} }
final class Push  { public function __construct(public string $deviceId) {} }

function send(Email|Sms|Push $msg): string {
    if ($msg instanceof Email) {
        return "email to {$msg->to}";
    }
    if ($msg instanceof Sms) {
        return "sms to {$msg->phone}";
    }
    return "?"; // forgotten Push branch — the language does not require it
}
Enter fullscreen mode Exit fullscreen mode

PHPStan does not flag the missing Push branch. The function returns string in every code path. The signature is honoured. The runtime will happily skip past Push and return "?". To get a compile-time-style error, you have to add the match + default throw shape and trust that PHPStan or a custom rule notices the unreachable-default invariant. Several PHPStan extensions try to bolt this on. None of them are part of the language.

TypeScript:

type Email = { kind: "email"; to: string };
type Sms   = { kind: "sms";   phone: string };
type Push  = { kind: "push";  deviceId: string };
type Msg   = Email | Sms | Push;

function assertNever(x: never): never {
  throw new Error(`unhandled: ${JSON.stringify(x)}`);
}

function send(msg: Msg): string {
  switch (msg.kind) {
    case "email": return `email to ${msg.to}`;
    case "sms":   return `sms to ${msg.phone}`;
    // forget "push"
    default:      return assertNever(msg);
  }
}
Enter fullscreen mode Exit fullscreen mode

tsc refuses to compile. The error is at the assertNever(msg) call. It names Push. You cannot ship the file. There is no level to set, no rule to enable, no extension to install. It is the language.

That is the contract difference. Same |, different floor.

Where PHP wins, credit where it is due

Reframing this only one way would be dishonest. PHP 8.4 added features that TypeScript would actually like to have, and PHP 8.5 keeps adding more. The gap closes from both sides as the languages evolve.

Asymmetric visibility lets you declare public private(set) string $name: readable from anywhere, writable only inside the class. TypeScript has readonly, but readonly is total. Nobody can write it after construction, including the class itself. Asymmetric visibility carves a finer line (PHP 8.4 release announcement).

PHP 8.4 properties can also have get and set hooks built into the property declaration, the way Kotlin and C# do. TypeScript still requires you to write a getter/setter pair in a class, or thread a Proxy through. PHP's syntax is shorter and lives next to the field.

And PHP 8.4's ReflectionClass::newLazyGhost plus newLazyProxy give you first-class lazy initialisation at the language level. TypeScript can do this with Proxy, but it is not idiomatic. ORM-heavy codebases used to bolt this on with __get magic; PHP 8.4 made it a real construct.

These are not consolation prizes. If you spend half your day in domain models with computed properties and selective immutability, PHP 8.4 (or 8.5) is genuinely a better field-level language than TypeScript today. TypeScript pulls ahead between the values rather than inside them.

Where TypeScript wins, and why it matters

The wins on the TypeScript side are about the type system as a tool for proving things across whole functions, not about decorating individual fields.

Structural exhaustiveness. Discriminated unions plus never is a closed loop. The compiler will not let you forget a case. Adding a variant becomes a typed refactor: the compile errors are the to-do list. Anywhere you can prove a value is never, you have proven the code is unreachable. Functions that throw or loop forever return never, and the compiler propagates that. PHP has no equivalent. The closest you get is function foo(): never (added in PHP 8.1), but that flags the function, not arbitrary unreachable points.

Type narrowing inside arrow functions and predicates. TypeScript narrows through .filter, through is predicates, through inferred return types. Code like messages.filter((m): m is Email => m.kind === "email") returns Email[]. PHP's array_filter only narrows when PHPStan's stubs cover the exact predicate shape, which they often do not.

Branded types and template literal types. A whole class of validation that PHP cannot express at the type level (Email & { __brand: "validated" }, `user_${string}`) is part of the everyday TypeScript toolkit.

Structural typing. TypeScript types are structural by default. { id: number; name: string } is the same type as any other shape with those two properties. PHP unions are nominal: Email|Sms is exactly those two classes (and their subclasses), nothing else.

The PHP side has better individual fields. The TypeScript side has better proofs across functions. Both are real wins. The mistake is assuming the syntax similarity means the contracts are similar. They are not.

The mindset shift: from instanceof reflex to tag reflex

This is the part PHP 8 developers tend to internalise after about a week.

In PHP, when you have a value-of-many-shapes problem, the reflex is to make a class for each shape and narrow with instanceof. Polymorphism by reflection. The class name is the discriminant, and the engine reads it for you.

In TypeScript, the reflex is to give every shape a kind (or type, or _tag) field with a string-literal value, narrow with a switch on it, and fence the bottom with assertNever. The discriminant is data, not class identity. The compiler narrows on the value of a string field, not on the metadata of an object.

A few practical shifts come with this:

  1. Use type, not class, for data. TypeScript classes exist, and they are useful for things that have behaviour and identity. For values that are just shapes (events, messages, results), plain object types with a kind discriminant are the idiom. This is the opposite of the PHP habit of reaching for a final class for every value object.

  2. The discriminant goes on the type, not separately. Do not put the kind field on a base interface that variants extend. Put it directly on each variant as a literal type. That is what gives the compiler the narrowing teeth.

  3. Use Result<T, E> instead of throwing for expected failures. This is the TypeScript-y replacement for the PHP try/catch reflex on domain errors. A Result is a discriminated union, { ok: true; value: T } | { ok: false; error: E }, and the compiler forces you to handle both branches.

  4. Prefer switch over if/else chains for unions. if/else works, but switch on the discriminant is what makes the exhaustiveness pattern read cleanly. The default branch becomes the assertNever site.

  5. Trust the compiler errors as a refactor checklist. When you add a variant to a union, the red squiggles across the codebase are the work to do. This is the actual payoff. PHPStan-at-level-10 plus runtime tests is close, but only the compiler is allowed to refuse the build.

You will write your first assertNever and feel slightly silly. Then you will add a variant, watch six call sites turn red in five seconds, fix them in five minutes, and stop feeling silly.

What this is not an argument for

Two clarifications to keep this honest.

This is not "drop PHPStan, use TypeScript". PHPStan at level 10 is one of the strongest static analysers any dynamic language has ever had. It is the reason serious PHP shops can refactor at all. The point is that PHPStan operates on a language whose type system was bolted on around 30 years of dynamic semantics. TypeScript was built from the start to be analysed. The compiler can assume things PHPStan cannot, because the language was designed to let it.

This is also not "PHP cannot do exhaustiveness". It can. match plus a default that throws plus a custom PHPStan rule that detects the closed-set pattern can give you 80% of the same shape. There are open-source extensions that do this. They are not the default contract of the language. In TypeScript, exhaustiveness via never is the default contract.

The difference is who is responsible. In PHP, you are responsible for choosing the pattern, installing the tooling, and remembering to use it. In TypeScript, the language is responsible, and you opt out (with as any, // @ts-ignore, unknown casts) when you have a reason.

The PHP-to-TypeScript move is not a syntax move

What is going to bite you on day three of your TypeScript codebase is not the lambda syntax, or the import shape, or the null vs undefined distinction. Those are paper cuts. They heal in a week.

By day fourteen what lands is the realisation that you are now in a language that lets you describe sets of values your PHP types could not describe, and the codebase you are walking into is built on the assumption that the compiler is your refactor partner, not just your warning system.

Your first three weeks in a real TypeScript codebase will feel like fighting the tooling. It is not the tooling. It is the contract you have been writing against in PHP, finally being made explicit.

If you want the long version of this, the migration patterns for every PHP 8+ feature that has a TypeScript counterpart, where the names match and the semantics do not, with side-by-side code for the whole journey: PHP to TypeScript in The TypeScript Library is written for exactly that reader.


If this was useful

The TypeScript Library is a 5-book set. If you are coming from PHP 8+, the natural path is #4 first, then #1 or #2 for depth.

The TypeScript Library — the 5-book collection

  • #1 — TypeScript Essentials — entry point. Types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, the browser.
  • #2 — The TypeScript Type System — the deep dive. Generics, mapped and conditional types, infer, template literals, branded types.
  • #3 — Kotlin and Java to TypeScript — bridge for JVM developers. Variance, null safety, sealed-to-unions, coroutines to async/await.
  • #4 — PHP to TypeScript — bridge for PHP 8+ developers. Sync to async paradigm, generics, discriminated unions, the exact mindset shift this post sketches.
  • #5 — TypeScript in Production — the production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

Books #1 and #2 are the core path. Book #4 substitutes for #1 if you are coming from PHP. Book #5 is for anyone shipping TypeScript at work.

Hermes IDE is an IDE for developers who ship with Claude Code and other AI coding tools — written in TypeScript, with the patterns this collection covers.

Top comments (0)