Porting to JS/TS, Part 3: TypeScript's type system is the payoff, not the tax
Part 2 of this series was about picking a landing runtime, Node.js or Bun, once you have decided to move off PHP. That question matters, but it is an infrastructure question. This post is about the part of the move that actually changes how your team writes and maintains code day to day: TypeScript's type system, and why "port to JS" is really shorthand for "port to TS" if you want the move to pay for itself.
We covered PHP's side of this gap already, in Leaving PHP, Part 3: a decade of incremental RFCs that added scalar types, union types, enums, and readonly properties, and still left generics out of the language entirely. This post picks up where that one left off. Given that PHP's type system has real, permanent gaps, what does TypeScript actually give you instead, and how do you get there without hand-typing a converted codebase from scratch?
Where PHP's type system still falls short in 2026
Property types, parameter types, union types, intersection types: PHP 8.4 has all of them, and a codebase that uses them consistently is meaningfully safer than the PHP 5 code most teams are still carrying somewhere. That is real progress, and it is also not the whole story. The gaps that remain are exactly the ones that show up on a large, long-lived codebase rather than in a single function signature.
Generics are the clearest example. Years of RFC discussion have not landed generics in the language, and there is no realistic timeline for it. A container class that is supposed to hold only User objects has no way to say so to the runtime. The closest PHP gets is a @template docblock that PHPStan or Psalm can read, which is useful for the IDE and useless for the interpreter. Pass the wrong type into a Collection<User> and PHP will accept it without complaint; the bug shows up later, wherever the code calls a User-only method on something that is not one.
Union and intersection types help, but mostly at the boundary. Declaring function find(int|string $id): User|null tells a caller what to expect at that one call site. It does not tell you, six months later, whether every branch inside find() still narrows that union correctly, and it does not propagate through a container the way a real generic parameter would. The type information is real, but it is local. Refactoring confidence across a codebase needs something that composes across files and call graphs, not just across a single signature.
What TypeScript gives you instead
TypeScript was built with generics as a first-class, load-bearing part of the type system from the start, not retrofitted onto a language that shipped without them. A Collection<User> in TypeScript is not a convention the compiler ignores. It is a type the compiler tracks through every method call, every array operation, every destructure, and flags the moment something that is not a User tries to get in.
Structural typing is the other half of the payoff. TypeScript does not ask whether two types share a common ancestor; it asks whether their shapes match. That means two objects built by entirely unrelated code paths, with no inheritance relationship at all, are interchangeable if their fields line up. PHP's type system is nominal: compatibility has to be declared through class hierarchies and interfaces, which means retrofitting a shared interface onto existing classes just to satisfy a type check. TypeScript skips that ceremony entirely.
Strict null checks close a category of bug that PHP developers know by its runtime symptom rather than its cause: the undefined array key notice, the "attempt to read property on null" fatal, the 3am page from an error tracker instead of a compiler. With strictNullChecks on, a value that might be null or undefined has to be narrowed before you can use it, and the compiler enforces that at every call site, not just the ones a docblock happened to annotate. The same bug that PHP catches in production, TypeScript catches before the code ships.
How Pext fits in
Pext's config.target setting can emit either plain JavaScript or TypeScript. That choice is not just JS with type annotations bolted on after the fact. The TypeScript output is inferred directly from what the PHP source already tells the transpiler: declared property and parameter types, docblock @var and @return annotations, and, when closed-world analysis is enabled, call-site shape inference across the whole source set.
That last part matters most for exactly the gap described above. A PHP codebase with good docblocks and consistent typed properties gives the transpiler enough to infer real TypeScript generics on the way out, the same Collection<User> guarantee that PHP could only gesture at through a comment. A codebase with weaker typing gets weaker inference, but never less safety than the JS output target would have given it; TypeScript output is additive, not a gate.
The practical upshot is that a team porting off PHP does not have to choose between shipping fast and getting TypeScript's guarantees. The conversion itself is the on-ramp. You do not hand-type a freshly converted codebase to get into the safety net this post describes; targeting TypeScript output gets you there as part of the same pass that gets you off PHP.
Part 4 closes out this series with the angle that is hardest to get right by hand: how AI coding tools perform differently against a typed TypeScript target than they do against loosely-typed PHP or plain JS, and what that means for how fast a team can move once the port is done.