TypeScript is a superset of JavaScript. Any JavaScript code is a valid TypeScript code, as long we set the compiler not to be strict. Therefore, TypeScript aims to be as flexible as possible so that it can apply to various situations. In this article, we look into type compatibility in TypeScript and explain what a structural type system is.
Type Compatibility in TypeScript
There are a lot of languages with a nominal type system. The above means that the two variables are compatible if they are of the same type. Let’s examine this C# code:
1 2 3 4 5 6 7 8 |
public class Employee { public string name; public Employee(string name) { this.name = name; } } |
1 2 3 4 5 6 7 8 |
public class Person { public string name; public Person(string name) { this.name = name; } } |
1 2 |
Employee john = new Employee('John'); john = new Person('John'); |
The above C# code causes an error. Due to a nominal type system, Employee and Person are not compatible. Similarly, such situations occur in languages like Java and C++.
The above behavior might help us to prevent mismatching types, but it is not very flexible. To give us more freedom, TypeScript implements a structural type system.
Structural type system
In a language with a structural type system, two types are compatible judging by their structure, instead of the name. The above allows TypeScript to adjust to the way that we often write the JavaScript code.
1 2 3 4 5 6 7 8 9 |
type Employee = { name: string; } class Person { public constructor (readonly name: string) {} } const john: Employee = new Person('John'); |
Above, we simplify the assignment of properties in the constructor of the Person class with the use of the readonly keyword
In TypeScript, the above code is perfectly valid. Going even further, we can use types that are not identical when it comes to its structure.
Structural subtyping
For one type to be compatible with the other, it needs to have at least the same properties.
1 2 3 4 5 6 7 8 9 10 11 12 |
interface Employee { name: string; workplaceType: string; } interface Person { name: string; } function printName(person: Person) { console.log(person.name); } |
Above, we have the printName function. Since the Employee has all the properties of a Person.
1 2 3 4 5 6 |
const john: Employee = { name: 'John', workplaceType: 'Music store' } printName(john); |
The fact that the Person is compatible with the Employee does not mean that it works the other way around.
1 2 3 |
function printWorkplace(person: Employee) { console.log(person.name); } |
1 2 3 4 5 |
const john: Person = { name: 'John', } printWorkplace(john); |
Argument of type ‘Person’ is not assignable to parameter of type ‘Employee’.
Property ‘workplaceType’ is missing in type ‘Person’ but required in type ‘Employee’.
The above happens because the Person does not have all the properties of the Employee.
Similar subtyping happens when the Employee extends the Person.
1 2 3 4 5 6 7 |
interface Person { name: string; } interface Employee extends Person { workplaceType: string; } |
In languages with nominal subtyping, it would be the only way to achieve compatible subtypes. Thanks to TypeScript being a structurally typed language, instead, “accidental” subtypes work issue-free.
Polymorphism
Calling the printName(person: Person) function using the Employee is an example of polymorphism. Since we know that the employee has all the properties of a person, we can treat its instance as such.
The most straightforward way to visualize it is with the use of an example with shapes and calculating their areas.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
interface Shape { getArea(): number; } class Circle { constructor(readonly radius: number) {} getArea() { return Math.pow(this.radius, 2) * Math.PI; } } class Square { constructor(readonly size: number) {} getArea() { return Math.pow(this.size, 2); } } |
Although neither Circle nor Square extends Shape explicitly, all of them have the getArea function. If that’s all we need, we can treat Circle and Square as a Shape.
1 2 3 4 5 6 7 8 9 10 |
const shapes = [ new Circle(10), new Square(5), new Circle(2), new Square(25) ] const sortedShapes = shapes.sort((firstShape: Shape, secondShape: Shape) => ( firstShape.getArea() - secondShape.getArea() )) |
Differentiating types with Type Guards
We don’t always have such straightforward types. Sometimes, we need to deal with unions and the unknown. Thankfully, TypeScript has mechanisms to help us with that. Such a situation might occur when we fetch the data from various APIs. Let’s say we want to fetch a user and print his workplace type if he is an employee.
1 2 3 |
function printWorkplaceType(employee: Employee) { console.log(employee.workplaceType); } |
1 |
type FetchUser = () => Promise<unknown>; |
1 2 3 4 |
fetchUser() .then((user) => { printWorkplaceType(user); }) |
Unfortunately, the above does not work. We experience an error:
Argument of type ‘unknown’ is not assignable to parameter of type ‘Employee’.
Type ‘{}’ is missing the following properties from type ‘Employee’: name, workplaceType
This is because we are not sure if what we fetch is a proper employee. At first glance, we might want to check the existence of the workplaceType property.
1 2 3 4 5 6 |
fetchUser() .then(user => { if (user.workplaceType) { printWorkplaceType(user); } }) |
The above does not work either, because Property 'workplaceType' does not exist on type 'unknown'. Even if we could check this property, the compiler wouldn’t treat it as a proper Employee.
We also can’t use the instanceof operator, because the Employee is just an interface. A solution to this issue are Type Guards.
Defining Type Guards
A type guard is a function that guarantees a type during a runtime check.
1 2 3 |
function isEmployee(user: any): user is Employee { return Boolean(user.workplaceType && user.name); } |
The user is Employee is a type predicate. Type predicates are a special return type that signals the type of a particular value.
Now, we can easily implement it in our logic to make sure that what we fetch is a proper Employee.
1 2 3 4 5 6 |
fetchUser() .then(user => { if (isEmployee(user)) { printWorkplaceType(user); } }) |
In the above code, we check the type in the runtime so that we can safely call the printWorkplaceType function.
We can make our code a bit cleaner using the in operator.
The
in
operator returnstrue
if the specified property is in the specified object or its prototype chain.
1 2 3 |
function isEmployee(user: any): user is Employee { return 'workplaceType' in user && 'name' in user; } |
Summary
In this article, we’ve reviewed two types of systems: structural and nominal. We’ve also looked through the consequences and reasons of TypeScript having the structural type system. We explained what polymorphism is and how we can apply it to TypeScript without extending interfaces explicitly. To help us in some situations, we’ve used type guards with type predicates and the in operator.