What is type compatibility?
Type compatibility in TypeScript is a fundamental concept that determines whether one type can be assigned to, or used in place of, another type. Unlike many other object-oriented languages that use nominal typing, TypeScript employs a structural subtyping system, often referred to as 'duck typing'.
What is Structural Subtyping (Duck Typing)?
In TypeScript, two types are compatible if they have similar 'shapes' or structures, regardless of where they originated. If type A has at least all the members of type B, then A is compatible with B (i.e., A can be assigned to B). This is often summarized as: 'If it walks like a duck and quacks like a duck, then it's a duck.'
This approach differs from nominal typing, where compatibility is determined by explicit declarations or the name of the type. For instance, in a nominally typed language, a variable of type 'Animal' could only hold objects explicitly declared as 'Animal' or a subtype of 'Animal'. In TypeScript, if an object has the required properties of 'Animal', it can be treated as an 'Animal'.
How Type Compatibility Works
Object Compatibility
When comparing two object types, the 'source' type (the type being assigned) must have at least all the members that the 'target' type (the type it's being assigned to) declares. Excess properties in the source type are generally ignored, but literal objects might trigger 'excess property checks' for immediate assignment.
interface Named {
name: string;
}
let x: Named;
let y = { name: 'Alice', location: 'Seattle' };
x = y; // OK, y has a 'name' property
interface Person {
name: string;
age: number;
}
let p: Person;
let anotherPerson = { name: 'Bob', age: 30 };
p = anotherPerson; // OK
let incompletePerson = { name: 'Charlie' };
// p = incompletePerson; // Error: Property 'age' is missing in type '{ name: string; }'
Function Compatibility
For functions, compatibility is determined by their parameters and return types. The rules are a bit more nuanced:
- Parameter List (Bivariance): A function with fewer parameters is compatible with a function that has more parameters, as long as the types of the common parameters match. TypeScript allows 'bivariance' for parameters by default, meaning parameters can be 'wider' or 'narrower' than the target type. This can sometimes lead to unsafe behavior but is usually desired for callback patterns. (Note:
strictFunctionTypescompiler option restricts this to contravariance for function parameters). - Return Type: The return type of the source function must be assignable to the return type of the target function (covariance).
let func1 = (a: number, b: number) => 0;
let func2 = (a: number) => 0;
func2 = func1; // OK: func2 can accept fewer arguments than func1
// func1 = func2; // Error (if 'strictFunctionTypes' is on, otherwise OK due to bivariance)
interface EventListener {
(e: Event): void;
}
interface MouseEventListener {
(e: MouseEvent): void; // MouseEvent is a subtype of Event
}
let listener: EventListener;
let mouseListener: MouseEventListener = (e: MouseEvent) => console.log(e.clientX);
listener = mouseListener; // OK: MouseEvent is assignable to Event
mouseListener = listener; // OK by default (bivariance), potentially unsafe if listener expects more general Event
Special Considerations
- Classes: Classes work similarly to object literals. If two classes have the same shape, they are compatible. However, private and protected members must originate from the same declaration to be compatible, making classes with such members nominally typed with respect to those members.
- Enums: Enums are compatible with numbers, and members of different enum types are not compatible with each other unless their underlying values are numbers and they match.
- Generics: Generic types are structurally compatible based on the instantiated types. If a generic type argument isn't used in any structural way, its compatibility is ignored.
anyandunknown:anyis compatible with all types and all types are compatible withany.unknownis less permissive; all types are assignable tounknown, butunknownis only assignable toanyor itself.readonlyproperties:readonlyproperties only need to be present in the target type, not necessarily markedreadonlyin the source type for assignment compatibility (though they must be assignable). When comparing tworeadonlyproperties, they must also be compatible.
Understanding type compatibility is crucial for writing robust and flexible TypeScript code, as it dictates how different parts of your system can interact without explicit casting or type assertions.