TypeScript’s type system is structural, meaning two types are compatible if they have the same shape, regardless of their names.
Let’s see this in action. Imagine we have two interfaces, Person and User, that happen to define the same properties:
interface Person {
name: string;
age: number;
}
interface User {
name: string;
age: number;
}
function greetPerson(person: Person) {
console.log(`Hello, ${person.name}! You are ${person.age} years old.`);
}
const myUser: User = {
name: "Alice",
age: 30
};
greetPerson(myUser); // This works!
Even though greetPerson expects a Person and myUser is of type User, the code compiles and runs without error. This is because TypeScript checks the structure of myUser and sees that it has a name property of type string and an age property of type number, which matches the Person interface’s requirements.
This is fundamentally different from nominal type systems (like Java or C#) where type compatibility is determined by names. In a nominal system, Person and User would be considered distinct types, and passing a User to a function expecting a Person would be a type error.
TypeScript’s structural typing offers a lot of flexibility. It allows for easier integration with existing JavaScript code, where explicit type declarations might be sparse. It also enables powerful patterns like duck typing: "If it walks like a duck and quacks like a duck, it’s a duck." If an object has the necessary methods and properties, TypeScript will treat it as compatible with the expected type, even if it wasn’t explicitly declared as such.
Let’s consider a more complex example with classes.
class Employee {
constructor(public name: string, public employeeId: number) {}
getDetails() {
return `${this.name} (ID: ${this.employeeId})`;
}
}
class Manager {
constructor(public name: string, public managerId: number) {}
getDetails() {
return `${this.name} (Manager ID: ${this.managerId})`;
}
}
function displayEmployeeInfo(emp: Employee) {
console.log(emp.getDetails());
}
const myManager = new Manager("Bob", 12345);
displayEmployeeInfo(myManager); // This also works!
Again, myManager is an instance of Manager, but displayEmployeeInfo expects an Employee. Because Manager has a name property (implicitly public, so structurally compatible with Employee’s public name) and an employeeId property (which Manager doesn’t have, but it does have a managerId which is structurally similar enough in this context for the basic structural check to pass for property access), and both have a getDetails() method that returns a string, TypeScript considers Manager structurally compatible with Employee for the purpose of this function.
The key here is that TypeScript checks for the presence and type of members. If an object has all the public members of a given type, it’s considered compatible.
This structural approach extends to more complex scenarios, including function types. Two function types are compatible if their parameter types are compatible and their return types are compatible.
type Callback = (data: string) => void;
function processData(callback: Callback) {
callback("some data");
}
const myHandler = (event: { detail: string }) => {
console.log("Received:", event.detail);
};
processData(myHandler); // Works
Here, myHandler is not declared to accept a string. However, its parameter event has a property detail which is a string. Since the Callback type expects a string argument, and myHandler can be called with a string (which it then accesses as event.detail), TypeScript sees the structural compatibility. It’s like saying, "Can myHandler accept a string and do something with it?" Yes, it can.
One of the less obvious consequences of structural typing is how it handles private members. Private members in TypeScript are truly private and are not part of the structural compatibility check. This means two classes with identically named private members are still considered structurally compatible.
Consider this:
class SecretAgent {
private secretCode: string;
constructor(code: string) {
this.secretCode = code;
}
reveal() {
console.log(`My code is ${this.secretCode}`);
}
}
class Spy {
private secretCode: string;
constructor(code: string) {
this.secretCode = code;
}
reveal() {
console.log(`My secret is ${this.secretCode}`);
}
}
const agent = new SecretAgent("007");
const spy = new Spy("L00P");
// The following would be a type error if private members were structural:
// const secretAgent: SecretAgent = spy; // Error in nominal, but also in structural if private mattered
// However, if we define a type that *only* looks at public members:
interface Revealer {
reveal(): void;
}
function expose(r: Revealer) {
r.reveal();
}
expose(agent); // Works
expose(spy); // Works
Even though both SecretAgent and Spy have a private secretCode property, they are structurally compatible with the Revealer interface because they both have a public reveal() method. The private secretCode property is invisible to the structural type checking mechanism when determining compatibility between different types. This is a crucial distinction that often trips people up, as they might expect private members to enforce a stronger form of type identity.
Understanding structural typing is key to leveraging TypeScript effectively, especially when working with complex object shapes and integrating with external JavaScript libraries. It’s a powerful, albeit sometimes counterintuitive, mechanism that prioritizes shape over name.
The next logical step is to explore how intersection types build upon this structural foundation.