TypeScript for PHP Developers: Concepts Compared
AI generated
<T>
type
TypeScript · PHP · Type Systems · Concepts
TypeScript for PHP Developers
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.

14 min read Union Types · Interfaces · Generics PHP 8.4 · TypeScript 5 · PHPStan

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.

11. FAQ: TypeScript for PHP Developers

1Is TypeScript hard to learn for experienced PHP developers?
No, PHP developers already know most concepts like union types, interfaces, and generics from PHP 8 and PHPStan. The biggest adjustment is the purely compile-time type system with no runtime checking.
2Are TypeScript types checked at runtime like in PHP?
No. TypeScript types only exist at compile time and are completely removed during transpilation. PHP actively checks declared types at runtime and throws a TypeError on violation.
3What is the difference between nominal and structural typing?
PHP: classes are only compatible through explicit inheritance or interfaces. TypeScript: compatibility is based solely on the object shape, regardless of name.
4Are TypeScript interfaces comparable to PHP interfaces?
Syntactically yes, functionally only to a limited extent. PHP interfaces are runtime constructs, TypeScript interfaces vanish completely after the build.
5Is a readonly field in TypeScript really immutable?
Only at compile time. After transpiling, no protection remains. True runtime immutability additionally requires Object.freeze().
6Should I use native enums or literal unions in TypeScript?
Literal unions are considered more idiomatic since they create no runtime overhead and combine better with JSON data exchange.
7How do TypeScript generics compare to PHPStan templates?
PHPStan simulates generics via @template docblocks, which PHP ignores at runtime. TypeScript has generics natively in the compiler, with Java/C#-like syntax.
8What is the TypeScript equivalent of PHP's nullsafe operator?
Optional chaining (?.) corresponds almost exactly, syntactically, to PHP's ?->-operator. For true null safety, also enable strictNullChecks.
9Is a TypeScript type alias enough to replace PHP's type checking for API data?
No. A type alias guarantees nothing at runtime for external data. That requires a parsing function or a validation library like Zod.
10Can structural typing in TypeScript disguise real bugs?
Yes. Two functionally different objects with identical field structure are compatible in TypeScript, even though PHPStan would immediately warn thanks to nominal typing.