Type compatibility in TypeScript is based on structural subtyping. Structural typing is a way of relating types based solely on their members. This is in contrast with nominal typing. Consider the following code:
ts
interface Pet {name: string;}class Dog {name: string;}let pet: Pet;// OK, because of structural typingpet = new Dog();
In nominally-typed languages like C# or Java, the equivalent code would be an error because the Dog
class does not explicitly describe itself as being an implementer of the Pet
interface.
TypeScript’s structural type system was designed based on how JavaScript code is typically written. Because JavaScript widely uses anonymous objects like function expressions and object literals, it’s much more natural to represent the kinds of relationships found in JavaScript libraries with a structural type system instead of a nominal one.
A Note on Soundness
TypeScript’s type system allows certain operations that can’t be known at compile-time to be safe. When a type system has this property, it is said to not be “sound”. The places where TypeScript allows unsound behavior were carefully considered, and throughout this document we’ll explain where these happen and the motivating scenarios behind them.
Starting out
The basic rule for TypeScript’s structural type system is that x
is compatible with y
if y
has at least the same members as x
. For example consider the following code involving an interface named Pet
which has a name
property:
ts
interface Pet {name: string;}let pet: Pet;// dog's inferred type is { name: string; owner: string; }let dog = { name: "Lassie", owner: "Rudd Weatherwax" };pet = dog;
To check whether dog
can be assigned to pet
, the compiler checks each property of pet
to find a corresponding compatible property in dog
.
In this case, dog
must have a member called name
that is a string. It does, so the assignment is allowed.
The same rule for assignment is used when checking function call arguments:
ts
interface Pet {name: string;}let dog = { name: "Lassie", owner: "Rudd Weatherwax" };function greet(pet: Pet) {console.log("Hello, " + pet.name);}greet(dog); // OK
Note that dog
has an extra owner
property, but this does not create an error.
Only members of the target type (Pet
in this case) are considered when checking for compatibility.
This comparison process proceeds recursively, exploring the type of each member and sub-member.
Comparing two functions
While comparing primitive types and object types is relatively straightforward, the question of what kinds of functions should be considered compatible is a bit more involved. Let’s start with a basic example of two functions that differ only in their parameter lists:
ts
let x = (a: number) => 0;let y = (b: number, s: string) => 0;y = x; // OKx = y; // Error
To check if x
is assignable to y
, we first look at the parameter list.
Each parameter in x
must have a corresponding parameter in y
with a compatible type.
Note that the names of the parameters are not considered, only their types.
In this case, every parameter of x
has a corresponding compatible parameter in y
, so the assignment is allowed.
The second assignment is an error, because y
has a required second parameter that x
does not have, so the assignment is disallowed.
You may be wondering why we allow ‘discarding’ parameters like in the example y = x
.
The reason for this assignment to be allowed is that ignoring extra function parameters is actually quite common in JavaScript.
For example, Array#forEach
provides three parameters to the callback function: the array element, its index, and the containing array.
Nevertheless, it’s very useful to provide a callback that only uses the first parameter:
ts
let items = [1, 2, 3];// Don't force these extra parametersitems.forEach((item, index, array) => console.log(item));// Should be OK!items.forEach((item) => console.log(item));
Now let’s look at how return types are treated, using two functions that differ only by their return type:
ts
let x = () => ({ name: "Alice" });let y = () => ({ name: "Alice", location: "Seattle" });x = y; // OKy = x; // Error, because x() lacks a location property
The type system enforces that the source function’s return type be a subtype of the target type’s return type.
Function Parameter Bivariance
When comparing the types of function parameters, assignment succeeds if either the source parameter is assignable to the target parameter, or vice versa. This is unsound because a caller might end up being given a function that takes a more specialized type, but invokes the function with a less specialized type. In practice, this sort of error is rare, and allowing this enables many common JavaScript patterns. A brief example:
ts
enum EventType {Mouse,Keyboard,}interface Event {timestamp: number;}interface MyMouseEvent extends Event {x: number;y: number;}interface MyKeyEvent extends Event {keyCode: number;}function listenEvent(eventType: EventType, handler: (n: Event) => void) {/* ... */}// Unsound, but useful and commonlistenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x + "," + e.y));// Undesirable alternatives in presence of soundnesslistenEvent(EventType.Mouse, (e: Event) =>console.log((e as MyMouseEvent).x + "," + (e as MyMouseEvent).y));listenEvent(EventType.Mouse, ((e: MyMouseEvent) =>console.log(e.x + "," + e.y)) as (e: Event) => void);// Still disallowed (clear error). Type safety enforced for wholly incompatible typeslistenEvent(EventType.Mouse, (e: number) => console.log(e));
You can have TypeScript raise errors when this happens via the compiler flag strictFunctionTypes
.
Optional Parameters and Rest Parameters
When comparing functions for compatibility, optional and required parameters are interchangeable. Extra optional parameters of the source type are not an error, and optional parameters of the target type without corresponding parameters in the source type are not an error.
When a function has a rest parameter, it is treated as if it were an infinite series of optional parameters.
This is unsound from a type system perspective, but from a runtime point of view the idea of an optional parameter is generally not well-enforced since passing undefined
in that position is equivalent for most functions.
The motivating example is the common pattern of a function that takes a callback and invokes it with some predictable (to the programmer) but unknown (to the type system) number of arguments:
ts
function invokeLater(args: any[], callback: (...args: any[]) => void) {/* ... Invoke callback with 'args' ... */}// Unsound - invokeLater "might" provide any number of argumentsinvokeLater([1, 2], (x, y) => console.log(x + ", " + y));// Confusing (x and y are actually required) and undiscoverableinvokeLater([1, 2], (x?, y?) => console.log(x + ", " + y));
Functions with overloads
When a function has overloads, each overload in the source type must be matched by a compatible signature on the target type. This ensures that the target function can be called in all the same situations as the source function.
Enums
Enums are compatible with numbers, and numbers are compatible with enums. Enum values from different enum types are considered incompatible. For example,
ts
enum Status {Ready,Waiting,}enum Color {Red,Blue,Green,}let status = Status.Ready;status = Color.Green; // Error
Classes
Classes work similarly to object literal types and interfaces with one exception: they have both a static and an instance type. When comparing two objects of a class type, only members of the instance are compared. Static members and constructors do not affect compatibility.
ts
class Animal {feet: number;constructor(name: string, numFeet: number) {}}class Size {feet: number;constructor(numFeet: number) {}}let a: Animal;let s: Size;a = s; // OKs = a; // OK
Private and protected members in classes
Private and protected members in a class affect their compatibility. When an instance of a class is checked for compatibility, if the target type contains a private member, then the source type must also contain a private member that originated from the same class. Likewise, the same applies for an instance with a protected member. This allows a class to be assignment compatible with its super class, but not with classes from a different inheritance hierarchy which otherwise have the same shape.
Generics
Because TypeScript is a structural type system, type parameters only affect the resulting type when consumed as part of the type of a member. For example,
ts
interface Empty<T> {}let x: Empty<number>;let y: Empty<string>;x = y; // OK, because y matches structure of x
In the above, x
and y
are compatible because their structures do not use the type argument in a differentiating way.
Changing this example by adding a member to Empty<T>
shows how this works:
ts
interface NotEmpty<T> {data: T;}let x: NotEmpty<number>;let y: NotEmpty<string>;x = y; // Error, because x and y are not compatible
In this way, a generic type that has its type arguments specified acts just like a non-generic type.
For generic types that do not have their type arguments specified, compatibility is checked by specifying any
in place of all unspecified type arguments.
The resulting types are then checked for compatibility, just as in the non-generic case.
For example,
ts
let identity = function <T>(x: T): T {// ...};let reverse = function <U>(y: U): U {// ...};identity = reverse; // OK, because (x: any) => any matches (y: any) => any
Advanced Topics
Subtype vs Assignment
So far, we’ve used “compatible”, which is not a term defined in the language spec.
In TypeScript, there are two kinds of compatibility: subtype and assignment.
These differ only in that assignment extends subtype compatibility with rules to allow assignment to and from any
, and to and from enum
with corresponding numeric values.
Different places in the language use one of the two compatibility mechanisms, depending on the situation.
For practical purposes, type compatibility is dictated by assignment compatibility, even in the cases of the implements
and extends
clauses.
Any
, unknown
, object
, void
, undefined
, null
, and never
assignability
The following table summarizes assignability between some abstract types.
Rows indicate what each is assignable to, columns indicate what is assignable to them.
A ”✓” indicates a combination that is compatible only when strictNullChecks
is off.
any | unknown | object | void | undefined | null | never | |
---|---|---|---|---|---|---|---|
any → | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ | |
unknown → | ✓ | ✕ | ✕ | ✕ | ✕ | ✕ | |
object → | ✓ | ✓ | ✕ | ✕ | ✕ | ✕ | |
void → | ✓ | ✓ | ✕ | ✕ | ✕ | ✕ | |
undefined → | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ | |
null → | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ | |
never → | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Reiterating The Basics:
- Everything is assignable to itself.
any
andunknown
are the same in terms of what is assignable to them, different in thatunknown
is not assignable to anything exceptany
.unknown
andnever
are like inverses of each other. Everything is assignable tounknown
,never
is assignable to everything. Nothing is assignable tonever
,unknown
is not assignable to anything (exceptany
).void
is not assignable to or from anything, with the following exceptions:any
,unknown
,never
,undefined
, andnull
(ifstrictNullChecks
is off, see table for details).- When
strictNullChecks
is off,null
andundefined
are similar tonever
: assignable to most types, most types are not assignable to them. They are assignable to each other. - When
strictNullChecks
is on,null
andundefined
behave more likevoid
: not assignable to or from anything, except forany
,unknown
,never
, andvoid
(undefined
is always assignable tovoid
).