Why TypeScript "thinks" differently than PHP
Anyone who treats TypeScript as just PHP with types quickly trips over structural type checking, reference semantics for objects, and the absence of nominal typing. This article explains the fundamental difference between primitive and object types, contrasts TypeScript's duck typing philosophy directly with PHP, and surfaces the most common misconceptions developers bring along when switching.
Table of Contents
- 1. Why primitive and object types form the core of the type system
- 2. The primitive types at a glance: string, number, boolean & co.
- 3. Object types: objects, arrays, functions, and classes
- 4. Value semantics vs. reference semantics: copying, comparing, mutating
- 5. Structural typing: TypeScript's core philosophy (duck typing)
- 6. Nominal typing: the PHP expectation and how it misleads
- 7. Type guards: typeof, instanceof, and custom guards
- 8. interface vs. type: declaring object shapes
- 9. Common pitfalls and best practices compared
- 10. Summary
- 11. FAQ
1. Why primitive and object types form the core of the type system
TypeScript's type system rests on a single fundamental distinction: primitive types versus object types. The first group includes string, number, boolean, null, undefined, symbol, and bigint, the second covers everything else, meaning plain objects, arrays, functions, and class instances. This split is not an academic footnote, it directly determines how values are stored in memory, copied, compared, and mutated. TypeScript merely adds a static checking layer on top of a distinction that already exists in JavaScript, without changing the underlying runtime rules at all.
For PHP developers using TypeScript for build scripts, headless frontends, or API clients, the mental model of "type" shifts noticeably. PHP checks type declarations at runtime via strict_types uniformly for scalars and objects, while TypeScript's types disappear completely at compile time (type erasure) and exist purely as a developer tool. Anyone who treats primitive and object types as equivalent, interchangeable "types" in the PHP sense quickly overlooks why two seemingly identical objects are not equal, or why a copy suddenly triggers side effects.
2. The primitive types at a glance: string, number, boolean & co.
TypeScript's seven primitive types are string, number, boolean, null, undefined, symbol, and bigint. Unlike many other languages, there is no separate int or float type, number covers both integers and decimals and internally rests on double-precision IEEE 754 floating point numbers. For integers beyond Number.MAX_SAFE_INTEGER (2^53 minus 1), bigint has been available since ES2020, recognizable by the n suffix as in 9007199254740993n. bigint and number cannot be mixed without explicit conversion, TypeScript reports that as an error.
null and undefined are standalone primitive types with exactly one value each. With the strictNullChecks option enabled (part of strict: true), they must appear explicitly in the type signature, for example as string | null, otherwise the compiler rejects the assignment. symbol creates guaranteed unique, immutable values, commonly used as object property keys that cannot collide with strings. All primitive types share two properties: they are immutable and are compared by content rather than by reference with ===.
// The seven primitive types in TypeScript
let userName: string = "Miron";
let age: number = 42;
let price: number = 19.99; // no separate "int"/"float" - just number
let isActive: boolean = true;
let middleName: string | null = null;
let nickname: string | undefined = undefined;
let userId: symbol = Symbol("user-id");
let bigCounter: bigint = 9_007_199_254_740_993n; // beyond Number.MAX_SAFE_INTEGER
// Primitives are compared by value
console.log(age === 42); // true
console.log("a" + "b" === "ab"); // true
// Primitives are immutable - operations always return a new value
let original = "hello";
let shouted = original.toUpperCase();
console.log(original); // "hello" - unchanged
console.log(shouted); // "HELLO" - a new string
3. Object types: objects, arrays, functions, and classes
Anything that is not a primitive type counts as an object type in TypeScript: plain objects, arrays (internally Array<T>), functions, class instances, and built-in objects such as Date, Map, Set, or RegExp. The decisive difference from primitives lies in reference semantics: a variable does not store the object's content itself, only a reference to its location on the heap. When that reference is assigned to a second variable, both variables point to exactly the same object in memory, not to two independent copies.
This reference semantics is a common source of errors when switching from PHP, where objects are also handled by reference by default, but arrays are copied by value as soon as they're assigned to a new variable. In TypeScript and JavaScript, arrays follow the same reference semantics as every other object type. A mutation via push(), splice(), or a direct index assignment changes the original array everywhere it is referenced, including inside a state-management object or an Alpine.js data structure that holds the same reference.
// Object types are reference types
interface CartItem {
sku: string;
quantity: number;
}
const itemA: CartItem = { sku: "MS-1234", quantity: 1 };
const itemB = itemA; // copies the reference, not the object
itemB.quantity = 5;
console.log(itemA.quantity); // 5 - itemA "changed" too, same object in memory
// Arrays, functions and class instances are all object types under the hood
console.log(typeof [1, 2, 3]); // "object"
console.log(typeof (() => {})); // "function" (a special case, but still a reference type)
console.log(typeof new Date()); // "object"
// Independent copy requires an explicit clone
const itemC: CartItem = { ...itemA }; // shallow copy
const itemD: CartItem = structuredClone(itemA); // deep copy
4. Value semantics vs. reference semantics: copying, comparing, mutating
The === operator behaves fundamentally differently for primitive and object types. Two strings with identical content are always ===, because primitives are compared by value. Two structurally identical objects, say { sku: 'MS-1' } and { sku: 'MS-1' }, are on the other hand never === to each other unless they are the same reference in memory. This rule holds regardless of how exactly the object content matches, TypeScript checks this at compile time no differently than JavaScript does at runtime.
Content-based comparisons need an explicit deep-equal check, for example via a library like lodash or simple JSON.stringify() comparisons for straightforward structures. The readonly modifier annotation is easily misleading here too: it only prevents a property from being reassigned at compile time, but disappears completely when compiled to JavaScript and offers no runtime protection whatsoever. Real immutability requires Object.freeze(), which however only sets a shallow lock, nested objects inside a frozen object remain mutable.
5. Structural typing: TypeScript's core philosophy (duck typing)
TypeScript's core philosophy is structural typing, colloquially called duck typing: "if it looks like a duck and quacks like a duck, it's a duck." Two types count as compatible as soon as they have the same shape, meaning the same properties with compatible types, regardless of whether they share a common class, an implemented interface, or no explicit relationship at all. This stands in sharp contrast to nominally typed languages like PHP, Java, or C#, where type compatibility is tied to class names and declared inheritance hierarchies.
In practice this means: a function expecting a parameter of an interface type accepts any object with a matching shape, whether it comes from a class instance, an object literal, or the return value of a completely unrelated function. That makes composition, mocking in tests, and integrating independent modules considerably easier, because no explicit inheritance relationship needs to be established, the shape just has to match.
// Structural typing: shape decides compatibility, not class identity or inheritance
interface Loggable {
log(): string;
}
class OrderConfirmation {
constructor(private orderId: string) {}
log(): string {
return `Order confirmed: ${this.orderId}`;
}
}
class DeploymentEvent {
constructor(private version: string) {}
log(): string {
return `Deployed version ${this.version}`;
}
}
// Both classes are unrelated - no shared base class, no "implements Loggable"
function writeToAuditLog(entry: Loggable): void {
console.log(entry.log());
}
writeToAuditLog(new OrderConfirmation("ORD-1001")); // works
writeToAuditLog(new DeploymentEvent("2.4.8-p4")); // also works - same shape is enough
// A plain object literal satisfies the interface too, no class required at all
writeToAuditLog({ log: () => "Manual log entry" }); // works
6. Nominal typing: the PHP expectation and how it misleads
PHP developers almost automatically bring the expectation that two classes with identical properties remain incompatible unless connected via inheritance or a shared interface, the way instanceof checks it in PHP. TypeScript behaves differently here: if two classes like UserId and ProductId both only have a single readonly value: string property, they are structurally identical, and TypeScript allows an assignment between them as soon as a type assertion or a loosely typed spot in the code forces it.
This silent category error carries particular weight with ID types, monetary amounts, or units of measurement, where a mix-up creates severe bugs that the compiler never catches. The solution is called branded types (also called opaque types): an additional marker property, one that doesn't exist at runtime, is attached to the underlying primitive type via an intersection type. From that point on, the compiler reliably rejects mixing structurally similar but semantically different values, with zero runtime overhead.
// The PHP habit: expecting class identity to matter, like instanceof in PHP
class UserId {
constructor(public readonly value: string) {}
}
class ProductId {
constructor(public readonly value: string) {}
}
function findUser(id: UserId): void {
console.log(`Looking up user ${id.value}`);
}
// This looks safe, but structurally UserId and ProductId are identical:
// both are "{ readonly value: string }" - TypeScript allows the mix-up silently
// if we accidentally construct the wrong wrapper or skip the constructor
const looksLikeUserId = { value: "PROD-42" } as UserId; // compiles, wrong at runtime
// Fix: brand the type so structurally identical shapes stop being interchangeable
type Branded<T, Brand extends string> = T & { readonly __brand: Brand };
type SafeUserId = Branded<string, "UserId">;
type SafeProductId = Branded<string, "ProductId">;
function toSafeUserId(value: string): SafeUserId {
return value as SafeUserId;
}
function findUserSafe(id: SafeUserId): void {
console.log(`Looking up user ${id}`);
}
// findUserSafe(toSafeProductId("PROD-42")); // now a compile error - brands differ
findUserSafe(toSafeUserId("USR-1")); // correct usage
7. Type guards: typeof, instanceof, and custom guards
typeof works reliably only for primitive types plus the special cases "function" and "object", but returns a uniform "object" for arrays, class instances, and most built-in objects, which makes it unsuitable for distinguishing individual object shapes. instanceof checks the prototype chain instead and works reliably for class instances, but fails for plain object literals or objects created via Object.create(). Array.isArray() is the correct, robust method for distinguishing arrays from other object types.
For more complex object shapes, especially ones coming from outside like API responses or JSON.parse() results, built-in operators aren't enough. Custom type guards with an is predicate return type (function isProduct(x: unknown): x is Product) combine an actual runtime check with a type narrowing that the compiler then respects throughout the entire following code block. Because TypeScript types structurally but gives no runtime guarantee for the actual shape of external data, such guards, often combined with validation libraries like zod, are the only reliable bridge between assumed and actual object structure.
8. interface vs. type: declaring object shapes
Both interface and type can describe object types, but differ in important details. interface is open to declaration merging: multiple interface Product { ... } declarations with the same name are automatically merged into one, a feature library authors use to extend existing types. type, by contrast, is closed, but can express unions, intersections, mapped types, and conditional types, constructs that aren't possible with interface alone.
For pure object shapes, TypeScript treats both variants as completely equivalent structurally, it doesn't matter for assignability whether a shape was declared via interface or type. A common rule of thumb has emerged: interface for public, potentially extendable contract types of a library or module, type for unions, utility compositions, and anything interface can't express syntactically. A class declaring implements Product only gets an additional local check at the class definition, everywhere else in the code the instance remains purely structurally compatible.
// interface: open, supports declaration merging, ideal for extendable contracts
interface Product {
sku: string;
price: number;
}
interface Product {
currency: string; // merges into the same Product interface automatically
}
// type: closed, but supports unions, intersections and mapped types
type DiscountedProduct = Product & { discountPercent: number };
type PaymentMethod = "invoice" | "creditcard" | "paypal"; // interfaces cannot express unions
// Both are structurally identical for assignability - TypeScript does not
// care whether the shape came from "interface" or "type"
const p: Product = { sku: "MS-1234", price: 49.9, currency: "EUR" };
const d: DiscountedProduct = { ...p, discountPercent: 10 };
class InvoiceProduct implements Product {
constructor(public sku: string, public price: number, public currency: string) {}
}
// A class instance is still just structurally checked everywhere outside "implements"
const anotherProduct: Product = new InvoiceProduct("MS-5678", 29.9, "EUR");
9. Common pitfalls and best practices compared
The following overview contrasts naive expectations carried over from other languages with TypeScript's actual behavior. Internalizing these five points avoids the most common bugs that arise from confusing primitive and object types.
| Criterion | Naive expectation | Actual behavior | Practical consequence |
|---|---|---|---|
| Comparison (===) | Two objects with the same content are equal | Objects compare by reference, not by content | Deep-equal check or structuredClone needed |
| Mutability | Number/string can be changed "in place" | Primitives are immutable, every change produces a new value | const is safe for primitives, not for objects |
| Copy behavior | let b = a copies the object content | Assignment only copies the reference | Spread or structuredClone for real copies |
| typeof result | typeof returns a distinct string per class | typeof returns a uniform "object" for objects, arrays, and classes | instanceof or duck-typing checks needed |
| Type compatibility | Two classes with identical properties are incompatible | TypeScript allows assignment given the same structure | Branded types needed if nominal behavior is desired |
In practice, these differences show up most at the boundaries with external data: API responses, JSON.parse() results, or form input carry no TypeScript type information at runtime anymore, because it's fully removed at compile time. Anyone who cleanly separates value and reference semantics and thinks structurally instead of nominally writes more robust code, regardless of whether that code ends up in a Magento build script, a headless frontend, or an API integration.
Mironsoft
TypeScript tooling, headless integrations, and type safety for Magento and Hyvä projects
Want to use TypeScript's type system properly across your team?
We analyze existing TypeScript codebases, uncover structural type errors and missing runtime validation, and align build scripts, API clients, and headless frontends around a robust, consistent type system.
Type System Audit
Uncovering structural type errors, unsafe assertions, and missing branded types
Runtime Validation
zod schemas at API boundaries so type promises hold true at runtime too
Team Coaching
Structural typing, strict mode, and best practices taught hands-on for PHP teams
10. Summary
Primitive types (string, number, boolean, null, undefined, symbol, bigint) are immutable and compared by value, object types (objects, arrays, functions, class instances) are mutable and compared by reference. This single distinction explains almost every behavior that first surprises developers switching from PHP to TypeScript: why two objects with equal content aren't ===, why a copy suddenly changes the original too, and why readonly creates no real runtime immutability.
The second, equally important distinction is structural instead of nominal typing: TypeScript checks compatibility based on a value's shape, not its class name or inheritance hierarchy. That makes code more flexible and easier to test, but it also opens the door to silent category errors, for example when ID types get accidentally swapped. Branded types, custom type guards, and runtime validation with libraries like zod close exactly this gap between compile-time safety and actual runtime reality.
Primitive vs. Object Types, the Key Takeaways
Primitive Types
Seven types (string, number, boolean, null, undefined, symbol, bigint). Immutable, compared by value.
Object Types
Objects, arrays, functions, class instances. Mutable, compared by reference on the heap.
Structural Typing
TypeScript's core philosophy: a value's shape decides, not its class name or inheritance.
PHP Pitfalls
Nominal expectations lead you astray. Branded types and zod validation close the gap.