How Does TypeScript Structural Typing Work?
TypeScript utilizes a structural type system—often referred to as compile-time duck typing—where type compatibility and equivalence are determined solely by an object's actual shape and properties rather than its explicit name or declaration site. In contrast to nominal type systems found in languages like Java, C#, or C++, where two entities must share an explicit inheritance chain or declaration to be considered interchangeable, TypeScript considers any two types compatible if one possesses all the public members required by the other. This architectural choice aligns directly with the dynamic nature of JavaScript, allowing idiomatic runtime patterns while maintaining robust compile-time safety.
Understanding Structural Typing in TypeScript
In a structural type system, the compiler evaluates types by inspecting their internal structure. When values are assigned to a variable, passed as function arguments, or returned from functions, TypeScript verifies that the incoming value satisfies the target type's structural contract.
Consider this basic example:
interface Point2D {
x: number;
y: number;
}
function printCoordinates(point: Point2D) {
console.log(`x: \({point.x}, y:\){point.y}`);
}
const vector = { x: 10, y: 20, z: 30 };
printCoordinates(vector); // ValidEven though vector was not explicitly declared as an
implementer of Point2D, and despite having an extra
property z, TypeScript permits the call because
vector satisfies the structural requirement of having
numeric x and y properties.
Comparing Structural and Nominal Typing
The core distinction between structural and nominal typing lies in what establishes identity and compatibility:
| Characteristic | Structural Typing (TypeScript) | Nominal Typing (Java, C#, Rust) |
|---|---|---|
| Compatibility basis | Shape, members, and internal signatures | Explicit name, namespace, and hierarchy |
| Type equivalence | Identical structures are identical types | Identical structures are distinct unless aliased or inherited |
| Declaration style | Flexible, implicit matching | Strict, explicit declaration
(class B implements A) |
| Runtime overhead | None (types are erased during compilation) | Often preserved for runtime reflection and dispatch |
| Primary design goal | Modeling dynamic JavaScript runtime patterns | Strict domain modeling and type segregation |
In a nominal language like Java, two classes with identical fields cannot be substituted for one another unless they share an explicit interface or parent class:
// Java (Nominal)
class User { public String id; }
class Order { public String id; }
User user = new Order(); // Compile-time error: incompatible typesIn TypeScript, identical structures are mutually assignable:
// TypeScript (Structural)
class User { id!: string; }
class Order { id!: string; }
let user: User = new Order(); // Fully validKey Mechanics of TypeScript's Structural System
TypeScript applies structural checks across several language constructs, each governed by specific rules:
- Object Width and Depth: Type compatibility checks verify that every required property in the target type exists in the source type with a compatible type. Excess properties on non-literal values are tolerated, accommodating typical JavaScript data extension patterns.
- Excess Property Checks on Literals: While
structural typing permits extra properties in intermediate variables,
TypeScript enforces strict excess property checks on direct object
literals. Writing
printCoordinates({ x: 10, y: 20, z: 30 })directly causes a compiler error to prevent typos and unused properties. - Function Parameter Bivariance and Contravariance:
TypeScript checks function compatibility by evaluating parameter lists
and return types. A function with fewer parameters can be assigned to a
location expecting more parameters, which is essential for handling
callback signatures in JavaScript libraries (such as
Array.prototype.forEach). - Classes as Value-and-Type Pairs: A TypeScript class
creates both a constructor function and an instance type. The structural
comparison only evaluates the instance side (public properties and
methods). Constructors and static members do not influence instance
compatibility. However,
privateandprotectedmembers introduce nominal behavior: they must originate from the exact same class declaration to be considered compatible.
Simulating Nominal Typing in TypeScript
When strict domain separation is necessary—such as preventing an unvalidated identifier from being passed where a validated entity ID is expected—developers can simulate nominal typing using branded or tagged types:
type UserId = string & { readonly __brand: unique symbol };
type OrderId = string & { readonly __brand: unique symbol };
function makeUserId(raw: string): UserId {
return raw as UserId;
}
function deleteUser(id: UserId) {
// Logic to delete user
}
const rawId = "user_12345";
const orderId = "order_67890" as OrderId;
deleteUser(rawId); // Error: string is not assignable to UserId
deleteUser(orderId); // Error: OrderId is not assignable to UserId
deleteUser(makeUserId(rawId)); // ValidBy intersecting a primitive type with a unique dummy property, the structural shape becomes impossible to satisfy accidentally, combining structural flexibility with nominal safety.