Concepts Compared
PHP developers already know most TypeScript concepts, just under different names and with different guarantees. This article maps union types, interfaces, immutability, enums, and generics from the PHP world onto their TypeScript equivalents, shows exactly where nominal and structural typing diverge, and compares a typical value object line by line in both languages.
Table of Contents
- 1. Why PHP developers learn TypeScript faster than expected
- 2. Union types: PHP 8 vs. TypeScript and narrowing
- 3. Interfaces and abstract classes: runtime vs. pure compile time
- 4. Nominal vs. structural typing: where the model breaks down
- 5. Readonly and immutability: guarantees compared
- 6. Enums: PHP 8.1 vs. union-of-literals in TypeScript
- 7. Generics: PHPStan templates vs. native TypeScript generics
- 8. Null safety: nullable types vs. strictNullChecks
- 9. DTO and value object comparison: PHP and TypeScript side by side
- 10. Summary
- 11. FAQ
1. Why PHP developers learn TypeScript faster than expected
Anyone coming from the PHP world and looking at TypeScript for the first time usually underestimates how much prior knowledge already applies. PHP has had scalar type declarations since version 7.0, union types since 8.0, enums and readonly properties since 8.1, and readonly classes since 8.2. Developers who already write modern PHP codebases with strict types, interfaces, and value objects are already thinking in the same categories as a TypeScript developer: what shape does this value have, which states can it take on, and what does the compiler actually guarantee at runtime.
The biggest misconception when switching is assuming TypeScript behaves like a stricter version of PHP. In reality, TypeScript is a pure compile-time type system with no runtime checking at all, while PHP actively enforces type declarations at runtime and throws a TypeError exception on violation. This shift, from an enforcing to a purely advisory type layer, is the thread running through almost every concept in this article, and it resurfaces in nearly every section.
2. Union types: PHP 8 vs. TypeScript and narrowing
PHP 8 introduced union types like string|int for parameters, properties, and return values. At runtime, the engine checks on every function call whether the value passed matches one of the types in the union, and throws a TypeError otherwise. In TypeScript, the syntax with string | number looks almost identical, but the check happens exclusively at compile time. At runtime, the transpiled JavaScript retains no union type information whatsoever, the type system is optimized away entirely (type erasure).
The practical difference shows up in narrowing, meaning restricting a union type to a concrete type within a code path. In PHP, gettype(), is_string(), or instanceof handle this, and PHPStan and Psalm correctly narrow the type for the rest of the code after such a check. TypeScript uses typeof for primitive types, instanceof for classes, and the in operator for structural checks, all built directly into the language rather than relying on a separate static analysis tool. The compiler automatically narrows the type in every branch of an if statement, with no extra annotation needed.
<?php
declare(strict_types=1);
final class PriceFormatter
{
/**
* Formats a price that may arrive as a formatted string or raw cents.
*
* @param string|int $price
*/
public function format(string|int $price): string
{
if (is_int($price)) {
// Narrowed to int: PHPStan knows $price is int here
return number_format($price / 100, 2) . ' EUR';
}
// Narrowed to string in the remaining branch
return trim($price);
}
}
// TypeScript equivalent: union type with native narrowing
function formatPrice(price: string | number): string {
if (typeof price === 'number') {
// Narrowed to number: TS knows price is number here
return `${(price / 100).toFixed(2)} EUR`;
}
// Narrowed to string in the remaining branch, no annotation needed
return price.trim();
}
3. Interfaces and abstract classes: runtime vs. pure compile time
PHP interfaces are runtime constructs: a class that implements an interface can be checked at runtime with instanceof, and the interface shows up in class_implements(). Abstract classes go further and can already ship concrete method implementations and constructor logic. Both constructs exist as real entries in the class hierarchy that the PHP engine knows about and can evaluate at runtime.
TypeScript interfaces, by contrast, are a purely compile-time construct with zero runtime footprint: they are completely stripped out during transpilation to JavaScript, there is no instanceof MyInterface, and there is no way to check at runtime whether an object satisfies a given interface. Type aliases (type Foo = {...}) behave similarly but differ from interfaces in that they can also name unions, intersections, and primitive types, whereas interfaces are limited to object shapes and their extension via extends. Anyone who actually wants to check at runtime whether an object has a certain shape needs an explicit type guard function in TypeScript that manually validates the structure using typeof or in checks.
<?php
declare(strict_types=1);
interface PriceProviderInterface
{
public function getPrice(): int;
}
final class CatalogPrice implements PriceProviderInterface
{
public function __construct(private readonly int $cents) {}
public function getPrice(): int
{
return $this->cents;
}
}
// Runtime check is possible in PHP
if ($object instanceof PriceProviderInterface) {
echo $object->getPrice();
}
// TypeScript interface: pure compile-time contract, erased at runtime
interface PriceProvider {
getPrice(): number;
}
class CatalogPrice implements PriceProvider {
constructor(private readonly cents: number) {}
getPrice(): number {
return this.cents;
}
}
// No "instanceof PriceProvider" at runtime, interfaces vanish after compilation.
// A manual type guard is required to check shape at runtime.
function isPriceProvider(value: unknown): value is PriceProvider {
return typeof value === 'object' && value !== null && 'getPrice' in value;
}
4. Nominal vs. structural typing: where the model breaks down
Probably the most important difference between the two type systems is the compatibility rule. PHP uses nominal typing: two classes are only compatible if one actually inherits from the other or both implement a shared interface, regardless of how similar their properties and methods look. A class Address with the fields street, city, zip is never compatible in PHP with a structurally identical class ShippingAddress, unless there is an explicit inheritance relationship. TypeScript, on the other hand, uses structural typing, often called "duck typing": two types are compatible as soon as their shape matches, regardless of name or any declared relationship. A function expecting a parameter of type { street: string; city: string; zip: string } happily accepts any object with those three fields, whether it was declared as Address, ShippingAddress, or not typed at all. This leads to a surprising consequence: two completely independently developed classes with an identical shape are interchangeable in TypeScript even though they have nothing to do with each other functionally, for example a Coordinate object with x/y and a PixelOffset object with the same field names. PHP developers unfamiliar with this trap wonder why TypeScript doesn't flag an obvious bug that PHPStan would have caught immediately through nominal type checking.
5. Readonly and immutability: guarantees compared
PHP 8.1 introduced readonly properties, and PHP 8.2 extended the concept to entire readonly classes. The key point: this guarantee is enforced by the PHP engine at runtime. Attempting to assign to an already initialized readonly property outside the constructor throws an Error exception, regardless of whether the caller bypasses type checking or not. Combined with constructor property promotion, immutable value objects can be declared in a few lines without getter boilerplate.
TypeScript also has a readonly modifier, both for class properties and for array and tuple types with readonly T[]. The fundamental difference: readonly in TypeScript is a purely compile-time check. After transpiling to JavaScript, no protection remains at all, direct access via plain JavaScript, an as any cast, or an untyped library can overwrite the supposedly immutable value without complaint. True runtime immutability in TypeScript only comes from additionally calling Object.freeze(), a JavaScript feature in its own right, not something the readonly modifier provides on its own.
<?php
declare(strict_types=1);
final readonly class Money
{
/**
* Immutable value object, enforced by the PHP engine at runtime.
*/
public function __construct(
public int $amountInCents,
public string $currency,
) {}
}
$price = new Money(1999, 'EUR');
// $price->amountInCents = 2500; // throws Error at runtime, not just a lint warning
// TypeScript equivalent: readonly is compile-time only
class Money {
constructor(
public readonly amountInCents: number,
public readonly currency: string,
) {}
}
const price = new Money(1999, 'EUR');
// price.amountInCents = 2500; // TS compile error, but works fine in plain JS at runtime
// True runtime protection requires Object.freeze(price) in addition to readonly
6. Enums: PHP 8.1 vs. union-of-literals in TypeScript
PHP 8.1 brought native enums with optional backing type support for int or string. A PHP enum is a real class with a fixed set of instances, can implement interfaces, can have its own methods, and is tracked by the engine as its own type at runtime. OrderStatus::Shipped is a singleton object, not just a plain string.
TypeScript does have its own enum keyword, but in practice a union of string or number literals is considered more idiomatic: type OrderStatus = 'pending' | 'shipped' | 'delivered'. The reason lies in several TypeScript-specific quirks of native enums: they generate extra runtime code (an object with bidirectional mapping for numeric enums), behave differently as const enum under isolatedModules compilation, and combine less cleanly with plain JSON data exchange. Literal unions, by contrast, are purely compile-time constructs with zero runtime overhead and behave exactly like ordinary strings, which makes them the preferred choice in modern TypeScript codebases, especially at API boundaries to JSON-based backends such as a Magento GraphQL endpoint.
<?php
declare(strict_types=1);
enum OrderStatus: string
{
case Pending = 'pending';
case Shipped = 'shipped';
case Delivered = 'delivered';
public function isFinal(): bool
{
return $this === self::Delivered;
}
}
$status = OrderStatus::Shipped;
// Idiomatic TypeScript alternative: union of string literals, zero runtime overhead
type OrderStatus = 'pending' | 'shipped' | 'delivered';
function isFinal(status: OrderStatus): boolean {
return status === 'delivered';
}
const status: OrderStatus = 'shipped';
7. Generics: PHPStan templates vs. native TypeScript generics
PHP still has no native generics in the language itself to this day. Static analysis tools like PHPStan and Psalm simulate generics through special docblock annotations such as @template T and @return T, which are completely ignored by the PHP engine at runtime. These annotations exist only so the analysis tool can consistently thread types through generic collection classes such as a Collection<Product> object, without PHP itself ever knowing about it.
TypeScript, by contrast, has generics as a native language feature, built directly into the compiler, using the same angle-bracket syntax as Java or C#: function first<T>(items: T[]): T. The compiler checks type parameters consistently across function boundaries, classes, and interfaces, including constraints (T extends { id: number }) and default types. For PHP developers already familiar with PHPStan templates, the transition is surprisingly small, the mental model of a generic collection class is identical, except the checking no longer runs through a separate external tool but is directly part of the language and works in any IDE without extra configuration.
| PHP Concept | TypeScript Equivalent | Key Difference |
|---|---|---|
Union Types (string|int) |
Unions (string | number) |
PHP checks at runtime, TS only at compile time |
| Interfaces | Interfaces / Type Aliases | TS interfaces no longer exist after the build |
Class Identity (instanceof) |
Object Shape | Caution: TS accepts structurally identical, functionally unrelated objects |
| readonly Properties | readonly modifier |
PHP throws a runtime error, TS only a compile error |
| Enums (PHP 8.1) | Literal unions or enum |
Literal unions are more idiomatic with zero runtime overhead |
8. Null safety: nullable types vs. strictNullChecks
PHP marks nullable types with a leading question mark, ?string is shorthand for string|null. The null coalescing operator ?? and, since PHP 8.0, the nullsafe operator ?-> reduce nested isset() checks to a single line. Without declare(strict_types=1) and consistent use of nullable types, however, null in PHP remains a value that can slip through virtually anywhere unnoticed if type checking isn't actively turned on.
TypeScript turns null safety into an explicit compiler option: strictNullChecks, part of strict: true by default. With the option enabled, null is no longer an automatic member of every type, a value of type string is then guaranteed to never be null unless the type is explicitly declared as string | null. Optional chaining (?.) and the nullish coalescing operator (??) correspond almost exactly, syntactically, to their PHP counterparts. The decisive advantage of strictNullChecks: the compiler forces an explicit check at every point a nullable value is used, before the code even compiles, whereas PHP only enforces this discipline through additional static analysis tools like PHPStan at a high level.
9. DTO and value object comparison: PHP and TypeScript side by side
A typical example that ties together almost all the concepts covered so far is an address value object, of the kind exchanged in a checkout flow between a Magento backend and a headless frontend. In PHP, the ideal way to define such an object is as a readonly class with constructor property promotion, optionally with an interface for the service contract layer and a fromArray() factory method for deserializing an API response.
The TypeScript equivalent uses either a class with readonly fields, analogous to the PHP version, or, far more common in headless frontend code, a plain type alias with no class and no runtime object, combined with a separate parsing function or a library like Zod for runtime validation at the API boundary. This combination of compile-time type plus runtime validator is the TypeScript substitute for what PHP gets automatically through its built-in type checking at the function boundary, because a type alias alone guarantees nothing at runtime the moment data arrives from outside, say from a fetch() response.
<?php
declare(strict_types=1);
final readonly class Address
{
public function __construct(
public string $street,
public string $city,
public string $zip,
public string $countryCode,
) {}
/**
* @param array{street: string, city: string, zip: string, countryCode: string} $data
*/
public static function fromArray(array $data): self
{
return new self(
$data['street'],
$data['city'],
$data['zip'],
$data['countryCode'],
);
}
}
// TypeScript equivalent: type-only shape plus a runtime parser at the API boundary
type Address = {
readonly street: string;
readonly city: string;
readonly zip: string;
readonly countryCode: string;
};
// The "type" alone gives zero runtime guarantee for data coming from fetch();
// a parser like this (or a Zod schema) is the TS substitute for PHP's built-in check.
function parseAddress(data: unknown): Address {
if (
typeof data !== 'object' || data === null ||
!('street' in data) || !('city' in data) ||
!('zip' in data) || !('countryCode' in data)
) {
throw new Error('Invalid address payload');
}
const raw = data as Record<string, unknown>;
return {
street: String(raw.street),
city: String(raw.city),
zip: String(raw.zip),
countryCode: String(raw.countryCode),
};
}
Mironsoft
Magento backend and TypeScript frontend from a single source
Combine TypeScript and Magento expertise?
We build Magento backends and headless TypeScript frontends with cleanly typed service contracts, consistent DTOs between PHP and TypeScript, and pragmatic build and tooling setups for your team.
Type System Audit
Reviewing PHPStan and TypeScript configuration for strict consistency
DTO Synchronization
Aligning value objects between the Magento API and the TypeScript frontend
Team Training
Hands-on workshop for PHP developers moving toward TypeScript
10. Summary
For PHP developers, TypeScript is not a foreign world but a shift of familiar concepts onto a different enforcement layer. Union types, interfaces, readonly properties, and enums exist in both languages, but TypeScript checks them exclusively at compile time and discards all type information afterward, while PHP actively enforces the same guarantees at runtime. Anyone who internalizes this difference avoids the most common beginner mistakes: relying on a type alias as runtime validation, or assuming a readonly field is immutable even though it is unprotected after the build.
The second decisive turning point is the shift from nominal to structural typing. While PHP only makes two classes compatible through an explicit inheritance or interface relationship, TypeScript is satisfied by a plain match in shape. This property is powerful for flexible API boundaries, but it can disguise real bugs when functionally different objects happen to share the same structure. With this knowledge in mind, a DTO like the address example from section 9 can be modeled consistently and type-safely in both languages.
TypeScript for PHP Developers - The Essentials at a Glance
Union Types
PHP checks string|int at runtime, TypeScript only at compile time. Narrowing via typeof/instanceof instead of gettype().
Interfaces
TS interfaces vanish completely after the build, no instanceof check possible at runtime.
Nominal vs. Structural
PHP checks class identity, TypeScript only the object shape. A source of surprising compatibility.
Readonly & Enums
PHP throws runtime errors, TS only compile errors. Prefer literal unions over enum in TS.