Type Systems Deep Dive
In a typed programming language, the type system needs a way to determine if a variable of type A can be used where a variable of type B is expected. For example, can you pass a Dog object to a function that expects an Animal?
There are two main systems for determining this compatibility: Nominal Typing and Structural Typing. Understanding this distinction is key to understanding why certain code is valid in a language like TypeScript or Go, but would be an error in Java or C#.
In a nominal type system, compatibility between types is based on their explicit names and declarations. Two types are compatible only if they have the same name, or if one is explicitly declared to be a subtype of the other (e.g., through inheritance).
Duck, it's not a Duck."class Dog and class Cat are never compatible, even if they happen to have the exact same properties and methods. However, if Dog is declared to extend Animal, then a Dog can be used where an Animal is expected.// We define two classes with the exact same structure.
class Person {
String name;
void greet() {
System.out.println("Hello, I am " + name);
}
}
class Employee {
String name;
void greet() {
System.out.println("Hello, I am " + name);
}
}
public class Main {
// This function explicitly requires a 'Person' object.
static void welcome(Person p) {
p.greet();
}
public static void main(String[] args) {
Person person = new Person();
person.name = "Alice";
welcome(person); // This is OK.
Employee employee = new Employee();
employee.name = "Bob";
// This is a COMPILE-TIME ERROR, even though Employee has the same shape as Person.
// In a nominal system, the names don't match, so the types are incompatible.
// welcome(employee); // Error: welcome(Person) cannot be applied to (Employee)
}
}
Explanation: Even though Person and Employee are structurally identical, the Java compiler rejects welcome(employee) because the name Employee is not the same as the name Person. To make this work, Employee would have to explicitly extend Person.
In a structural type system, compatibility between types is based on their shape or structure. If two types have the same structure—meaning they have the same set of properties and methods with compatible types—then the types are considered compatible.
This is often called "duck typing", from the phrase: "If it walks like a duck and it quacks like a duck, then it must be a duck."
// We define two classes with the same structure.
class Person {
name: string;
greet(): void {
console.log(`Hello, I am ${this.name}`);
}
}
class Employee {
name: string;
greet(): void {
console.log(`Hello, I am ${this.name}`);
}
}
// This function expects an object with a 'name' (string) and a 'greet' (function).
function welcome(p: { name: string; greet: () => void }) {
p.greet();
}
let person = new Person();
person.name = "Alice";
welcome(person); // OK, Person has the required structure.
let employee = new Employee();
employee.name = "Bob";
// This is also OK! TypeScript sees that the 'Employee' class has the same
// shape as what the 'welcome' function expects, so it allows it.
welcome(employee);
// This would also work with a plain object literal
let celebrity = {
name: "Charlie",
age: 40, // Extra properties are fine
greet() { console.log(`Hi, I'm ${this.name}`); }
};
welcome(celebrity); // OK, it has the required 'name' and 'greet' properties.
Explanation: TypeScript doesn't care that Employee isn't named Person. It only cares that Employee has a name: string property and a greet(): void method, which satisfies the "contract" required by the welcome function.
| Feature | Nominal Typing | Structural Typing |
|---|---|---|
| Basis | Type names and explicit declarations (class A, interface B). | Type structure or shape (properties and methods). |
| Flexibility | Less flexible. Requires explicit relationships (e.g., implements, extends). | More flexible. Allows for "ad-hoc" polymorphism. Types from different libraries can be compatible if they have the same shape. |
| Safety | Arguably safer / more explicit. Type relationships are intentional. You can't accidentally satisfy an interface. | Can be less safe. You might accidentally implement an interface just by having methods with the same name, even if the intent is different. |
| Refactoring | Easier to refactor. Renaming a class will be caught by the compiler everywhere it's used. | Harder to refactor. Renaming a type might not cause an error if another type still matches the old structure. |
| Use Case | Good for large, stable codebases where relationships are well-defined and explicit intent is important (e.g., enterprise applications). | Good for codebases that need to integrate with third-party code or for rapid prototyping where flexibility is key. |
| Languages | Java, C#, C++, Rust | TypeScript, Go, Elm |