Understand TypeScript’s Structural Type System
TypeScript is a superset of JavaScript. On top of what JavaScript offers, TypeScript adds an optional static type system to it. The type system requires developers to define types for different parts of the program or it will automatically infer ones. The magic of TypeScript comes from its ability to catch bugs during compile time by spotting the inconsistency between the types declared and their usages. Understanding how the type checking mechanism works and its logic for assessing type compatibility is crucial to the design of program types which provide type safety. However, there are many typing systems which have different rules of typing checking. This article aims to introduce the system TypeScript uses and discuss the implications to type design.
Structural type system
How does TypeScript determine whether an object is compatible with a specific interface? In a strict sense, an object with its declaration implementing the interface and properties identical to the interface requirements can be safely claimed to be compatible with the interface. But more often we have objects that are not so perfectly matched, especially when object literals are common in JavaScript.
TypeScript adopts a structural type system which determines type compatibility and equivalence based on the type structure or definition rather than the declarative relationship between types and interfaces, which contrasts with nominal type system.
Let’s look at an example:
Function triggerWalk
takes a parameter walker
of type Walker
and invokes its walk
method. Since the interface of Walker
requires only a walk
method and variable person
has it, person
is compatible with the interface requirement of Walker
and therefore TypeScript does not throw any error.
One must be aware that variable person
has also a talk
method, which makes it not identical with interface Walker
. TypeScript’s structural type system accepts person
so long as it has all properties the interface requires.
Also that the declaration of variable person
does not involve keywords like implements
or extends
to interface Walker
, and the variable itself is not declared to be of type Walker
, how the variable is declared is not important to the type checking.
Structural typing is more flexible and has more concise syntax. However, it also means that relationships between types are more implicit and not as obvious as a nominal type system. The flexibility also makes catching certain of bugs difficult. As a result, TypeScript has added a process called excess property checking to it.
Excess property checking
Given the abovementioned, the following example might be confusing:
If the structural type system cares only whether the required properties are present, why would there be an error complaining the existence of talk
method? The main difference between this and the previous example is that triggerWalk
is invoked with an object literal rather than a variable to the object literal as the argument. This has triggered a process called excess property checking which throws errors on properties not stated in the interface.
Excess property checking is also triggered when you assign an object literal to a variable with type declared. It is added to TypeScript to limit the possible values of object literals and provide some safeguards.
To bypass this checking, you can create an intermediate variable and set it to the object literal. Since the type system is not checking an object literal directly now, excess property checking is not triggered and the error goes away.
Type design implications
As a structural type system determines types’ compatibility based solely on the properties they possess, this can create certain confusing results.
Take the following as an example:
Interface Goose
possesses all the properties interface Duck
requires so a goose is a duck too, at least from the type system’s perspective. To frame it more precisely, Goose
is a subtype of Duck
. While we know that both geese and ducks are birds, they are not the same kind of animal. It does not sound correct to say a goose is a duck.
One way we can correct this is to give the interfaces more descriptive names. Instead of taking a parameter of interface Duck
, we can create an interface Walkable
and type the parameter of it. Also, for the name of the function, instead of naming it walkDuck
, we can give it a more generalized name walk
. Now it makes better sense to say a goose is walkable and we have a walk
function that accepts any walkable objects.
Another way we can cope with the problem is to add a tag to interface Duck
. In the example below, a property called type
is added to Duck
. It acts like an identity check to other types. To be a subtype of Duck
, we have to add to Goose
the property of type
equal to “duck”, which developers should not do. Compared with the former approach, the latter approach is more preventive in nature. It makes the type more verbose but easier to catch up for developers from nominal typing languages.
Conclusion
Understanding the structural typing mechanism of TypeScript is important. It helps developers to design more flexible and concise types. It can also be confusing to developers from nominal typing languages as the implicit relationships between types require a different mindset of type design.