Understanding Type Compatibility in TypeScript
TypeScript employs a structural type system—often described as 'duck typing'—where type compatibility is determined by the shape (i.e., structure) of values rather than their declared names.
For example, two classes with identical member declarations are considered compatible, even if they have different names:
class Position {
x: number;
y: number;
}
class Coordinate {
x: number;
y: number;
}
const loc: Position = new Coordinate(); // ✅ No error
This works because Coordinate has at least all the members required by Position, satisfying the assignability rule: type A is compatible with type B if A has all members of B, and each corresponding member is of a compatible type.
Extending this idea, a richer type can be assigned to a more constrained one:
class Vector3D {
x: number;
y: number;
z: number;
}
const pos: Position = new Vector3D(); // ✅ Valid: Vector3D satisfies Position's shape
Interfaces follow the same principle. A type with extra properties remains assignable to a narrower interface:
interface Point2D {
x: number;
y: number;
}
interface Point3D {
x: number;
y: number;
z: number;
}
let p2: Point2D;
let p3: Point3D = { x: 0, y: 0, z: 0 };
p2 = p3; // ✅
Classes and interfaces are also mutually compatible when shapes align:
class Vector4D {
x: number;
y: number;
z: number;
w: number;
}
let point: Point2D = new Vector4D(); // ✅
Function compatibility involves three aspects: parameter count, parameter types, and return types.
Parameter count: A functon with fewer parameters can be assigned to one expecting more. The exces parameters are ignored at runtime, and TypeScript permits this for callback-style usage:
type UnaryFn = (value: string) => void;
type BinaryFn = (value: string, index: number) => void;
const log: UnaryFn = (v) => console.log(v);
const handler: BinaryFn = log; // ✅
['a', 'b'].forEach(log); // Also valid — index and array args are optional in practice
Parameter types: Corresponding parameters must be compatible. For object-typed parameters, structural subtyping applies:
interface BasicPoint {
x: number;
y: number;
}
interface ExtendedPoint extends BasicPoint {
z: number;
}
type AcceptBasic = (p: BasicPoint) => void;
type AcceptExtended = (p: ExtendedPoint) => void;
const handleBasic: AcceptBasic = (p) => console.log(p.x);
const handleExtended: AcceptExtended = handleBasic; // ✅
Note that this may seem counterintuitive at first—AcceptBasic accepts less data but is assignable to AcceptExtended. This reflects contravariance in parameter positions.
Return types: Return types must be covariant. A function returning a more specific (i.e., narrower or richer) type can be assigned where a less specific one is expected:
type GetString = () => string;
type GetNumber = () => number;
// ❌ Not compatible: string ≠ number
interface Person {
name: string;
}
interface Employee extends Person {
id: number;
}
type MakePerson = () => Person;
type MakeEmployee = () => Employee;
const makeEmp: MakeEmployee = () => ({ name: 'Alice', id: 1 });
const makeP: MakePerson = makeEmp; // ✅ Employee satisfies Person
In summary, structural compatibility in TypeScript prioritizes member presence and type alignment over nominal identity, enabling flexible yet type-safe composition across classes, interfaces, and functions.