You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Published on 28 December, 2021Last updated on 26 February, 2022
A reflection on my mental model of TypeScript’s type system
Try read the following TypeScript code snippet and work it out in your head to predicate whether or not there would be any type errors for each assignment:
If you were able to come up with the correct answers without pasting the code into your editor and let the compiler does its job, I am genuinely going to be impressed. At least I couldn’t get them all right despite writing TypeScript for more than a year. I was really confused by this part of TypeScript which involves types like any, unknown, void and never
I realized I didn’t have the correct mental model for how those types works. Without a consistent and accurate mental model, I could only rely on my experience or intuitions or constant trial and error from playing with the TypeScript compiler.
The blog post is my attempt to introspect and rebuild the mental model of TypeScript’s type system.
A warning up front: this is not a short article. You can jump directly to the section where I explore the type hierarchy tree if you are in a hurry.
Turns out all types in TypeScript take their place in a hierarchy. You can visualize it as a tree-like structure. Minimally, in a tree, we can a parent node and a child node. In a type system, for such a relationship, we call the parent node a supertype and the child node a subtype.
You are probably familiar with inheritance, one of the well-known concepts in object-oriented programming. Inheritance establishes an is-a relationship between a child class and a parent class. If our parent class is Vehicle, and our child class is Car, the relationship is “Car is Vehicle”. However it doesn’t work the other way around - an instance of the child class logically is not an instance of the parent class. “Vehicle is not Car”. This is the semantic meaning of inheritance, and it also applies to the type hierarchy in TypeScript.
According to the Liskov substitution principle, instances of Vehicle (supertype) should be substitutable with instances of its child class (subtype) Cars without altering the correctness of the program. In other words, If we expect a certain behavior from a type (Vehicle), its subtypes (Car) should honor it.
I should mention that the Liskov substitution principle is from a 30-year-old paper written for PhD's. There are a ton of nuances to it that I cannot possibly cover in one blog post.
Putting this together, in TypeScript, you can assign/substitute an instance of a type’s subtype to/with an instance of that (super)type, but not the other way around.
By the way I just realize the meaning of the word “substitute” changes radically depending on the preposition that follows it. In this blog post, when I say "substitute A with B”, it means we end up with B instead of A.
There are two ways in which supertype/subtype relationships are enforced. The first one, which most mainstream statically-typed languages (such as Java) use, is called nominal typing, where we need to explicitly declare a type is the subtype of another type via syntax like class Foo extends Bar. The second one, which TypeScript uses is structural typing, which doesn’t require us to state the relationship explicitly in the code. An instance of Foo type is a subtype of Bar as long as it has all the members that Bar type has, even if Foo has some additional members.
Another way to think about this supertype-subtype relationship is to check which type is more strict, type {name: string, age: number} is more strict than the type {name: string} since the former requires more members defined in its instances. Therefore type {name: string, age: number} is a subtype of type {name: string}.
two ways of checking assignability/substitutability#
One last thing before we dive into the type hierarchy tree in TypeScript:
type cast: you can just assign a variable of one type to a variable of another type to see if it raises a type error. More on that later.
the extends keyword -you can extend one type to another:
In TypeScript, there are two types are that the supertypes of all other types: any and unknown.
They accept any value of any type, encompassing all other types.
This graph is by no means an exhaustive list of all the types that TypeScript has. Check out the source code of TypeScript if you are interested to see all the types that it currently supports.
There are two types of type cast - upcast and downcast.
Assigning a subtype to its supertype is called upcast. By the Liskov substitution principle, upcast is safe so the compiler lets you do it implicitly, no questions asked.
There are exceptions where TypeScript disallows the implicit upcast. I will address that at the end of the post.
You can think of upcast similiar to walking up the tree - replacing (sub)types that are more strict with their supertypes that are more generic.
For example, every string type is a subtype of the any type and the unknown type. That means the following assignments are allowed:
The opposite is called downcast. Think of it as walking down the tree - replacing the (super)type that are more generic with their subtypes that are more strict.
Unlike upcast, downcast is not safe and most strongly typed languages don’t allow this automatically. As an example, assigning variables of the any and unknown type to the string type is downcast:
When we assign unknown to a string type, the TypeScript complier gives us a type error, which is expected since it is downcast so it cannot be performed without explicitly bypassing the type checker.
However TypeScript would happily allow us to assign any to a string type, which seems contradictory to our theory.
The exception here with any is because, in TypeScript the any type exists to act as a backdoor to escape to the JavaScript world. It reflects JavaScript’s overarching flexibility. Typescript is a compromise. This exception exists not due to some failure in design but the nature of not being the actual runtime language as the runtime language here is still JavaScript.
The never type is the bottom for the tree, from which no further branches extend.
Symmetrically, the never type behaves like the an anti-type of the top types - any and unknow, whereas any and unknown accept all values, never doesn’t accept any value (including values of the any type) at all since it is the subtype of all types.
If you think hard enough, you might have realized that never should have an infinite amount of types and members, as it must be assignable or substitutable to its supertypes, i.e. every other type in the type system in TypeScript according to the Liskov substitution principle. For example, our program should behave correctly after we substitute number and string with never since never is the subtype of both string and number types and it shouldn’t break the behavior defined by its supertypes.
Technically this is impossible to achieve. Instead, TypeScript makes never an empty type (a.k.a an uninhabitable type): a type for which we cannot have an actual value at runtime, nor can we do anything with the type e.g. accessing properties on its instances. The canonical usecase for never is when we want to type a return value from a function that never returns.
A function might not return for several reasons: it might throw an exception on all code paths, it might loop forever because it has the code that we want to run continuously until the whole system is shut down, like the event loop. All these scenarios are valid.
The assignment above might seem wrong to you at first - if never is an empty type, why is that we can assign it to a number type? The reason why such an assignment is fine is that the compiler knows that our function never returns so nothing will ever be assigned to the number variable. Types exist to ensure that the data is correct at runtime. If the assignment never actually happens at runtime, and the compiler knows that for sure in advance, then the types don’t matter.
There is another way to produce a never type is to intersect two types that aren’t compatible - e.g. {x: number} & {x: string}.
Edit from the future: I realized that there are some nuances to the resulting type - if disjoint properties are considered as discriminant properties (roughly, those whose values are of literal types or unions of literal types), the whole type is reduced to never. This is a feature introduced in TypeScript 3.9. Check out this PR for details and motivation.
We have talked about the top types and the bottom type. The types in between are just the other regular types you use everyday - number, string, boolean, composite types like object etc.
There shouldn’t be too much surprise as to how those types work once we have established the correct mental model:
it is allowed to assign a string literal type e.g. let stringLiteral: 'hello' = 'hello' to a string type (upcast) but not the other way around (downcast)
it is allowed to assign a variable holding an object of a type with extra properties to an object of a type with less properties when the existing properties’ types match (upcast) but not the other way around (downcast)
Or assign an non-empty object to an empty object:
However there is one type I want to talk more about in this section since people often confuse it with the bottom type never and that type is void.
In many other languages, such as C++, void is used as the a function return type that means that function doesn't return. However, in TypeScript, for a function that doesn't return at all, the correct type of the return value is never.
So what is the type void in TypeScript? void in TypeScript is a supertype of undefined - TypeScript allows you to assign undefined to void (upcaset) but again, not the other way around (downcast)
This can also be verified via the extends keyword:
void is also an operator in javascript that evaluates the expression next to it to undefined, e.g. void 2 === undefined // true.
In TypeScript, the type void is used to indicate that the implementer of a function is making no guarantees about the return type except that it won’t be useful to the callers. This opens the door for a void function at runtime to return something other than undefined, but whatever it returns shouldn’t be used by the caller.
At first blush this might seem like a violation for the Liskov substitution principle since the type string is not a subtype of void so it shouldn’t be able to be substitutable for void. However, if we view it from the perspective of whether or not it alters the correctness of the program, then it becomes apparent that as long as the caller function has no business with the returned value from the void function (which is exactly the intended outcome of the void type), it is pretty harmless to substitute that with a function that returns something different.
This is where TypeScript is trying to be pragmatic and complements the way JavaScript works with functions. In JavaScript it is pretty common when we reuse functions in different situations with the return values being ignored.
Another cool tip about void type (credit to @simey) is that you can annotate this with void when declaring a function:
This prevents you from using this inside the function.
situations where TypeScript disallows implicit upcast#
Generally there are two situations, and to be honest it should be pretty rare to find yourself in these situations:
When we pass literal objects directly to function
When we assign literal objects directly to variables with explicit types
The type hierarchy tree - zhenghao
https://ift.tt/R25GZuc
The type hierarchy tree
#typescriptPublished on 28 December, 2021Last updated on 26 February, 2022
A reflection on my mental model of TypeScript’s type system
Try read the following TypeScript code snippet and work it out in your head to predicate whether or not there would be any type errors for each assignment:
If you were able to come up with the correct answers without pasting the code into your editor and let the compiler does its job, I am genuinely going to be impressed. At least I couldn’t get them all right despite writing TypeScript for more than a year. I was really confused by this part of TypeScript which involves types like
any
,unknown
,void
andnever
I realized I didn’t have the correct mental model for how those types works. Without a consistent and accurate mental model, I could only rely on my experience or intuitions or constant trial and error from playing with the TypeScript compiler.
The blog post is my attempt to introspect and rebuild the mental model of TypeScript’s type system.
It is a hierarchy tree#
Turns out all types in TypeScript take their place in a hierarchy. You can visualize it as a tree-like structure. Minimally, in a tree, we can a parent node and a child node. In a type system, for such a relationship, we call the parent node a supertype and the child node a subtype.
You are probably familiar with inheritance, one of the well-known concepts in object-oriented programming. Inheritance establishes anis-a
relationship between a child class and a parent class. If our parent class isVehicle
, and our child class isCar
, the relationship is “Car
isVehicle
”. However it doesn’t work the other way around - an instance of the child class logically is not an instance of the parent class. “Vehicle
is notCar
”. This is the semantic meaning of inheritance, and it also applies to the type hierarchy in TypeScript.According to the Liskov substitution principle, instances of
Vehicle
(supertype) should be substitutable with instances of its child class (subtype)Cars
without altering the correctness of the program. In other words, If we expect a certain behavior from a type (Vehicle
), its subtypes (Car
) should honor it.Putting this together, in TypeScript, you can assign/substitute an instance of a type’s subtype to/with an instance of that (super)type, but not the other way around.
nominal and structural typing#
There are two ways in which supertype/subtype relationships are enforced. The first one, which most mainstream statically-typed languages (such as Java) use, is called nominal typing, where we need to explicitly declare a type is the subtype of another type via syntax like
class Foo extends Bar
. The second one, which TypeScript uses is structural typing, which doesn’t require us to state the relationship explicitly in the code. An instance ofFoo
type is a subtype ofBar
as long as it has all the members thatBar
type has, even ifFoo
has some additional members.Another way to think about this supertype-subtype relationship is to check which type is more strict, type
{name: string, age: number}
is more strict than the type{name: string}
since the former requires more members defined in its instances. Therefore type{name: string, age: number}
is a subtype of type{name: string}
.two ways of checking assignability/substitutability#
One last thing before we dive into the type hierarchy tree in TypeScript:
extends
keyword -you can extend one type to another:the top of the tree#
Let's talk about the type hierarchy tree.
In TypeScript, there are two types are that the supertypes of all other types:
any
andunknown
.They accept any value of any type, encompassing all other types.
upcast & downcast#
There are two types of type cast - upcast and downcast.
Assigning a subtype to its supertype is called upcast. By the Liskov substitution principle, upcast is safe so the compiler lets you do it implicitly, no questions asked.
You can think of upcast similiar to walking up the tree - replacing (sub)types that are more strict with their supertypes that are more generic.
For example, every
string
type is a subtype of theany
type and theunknown
type. That means the following assignments are allowed:The opposite is called downcast. Think of it as walking down the tree - replacing the (super)type that are more generic with their subtypes that are more strict.
Unlike upcast, downcast is not safe and most strongly typed languages don’t allow this automatically. As an example, assigning variables of the
any
andunknown
type to thestring
type is downcast:When we assign
unknown
to astring
type, the TypeScript complier gives us a type error, which is expected since it is downcast so it cannot be performed without explicitly bypassing the type checker.However TypeScript would happily allow us to assign
any
to astring
type, which seems contradictory to our theory.The exception here with
any
is because, in TypeScript theany
type exists to act as a backdoor to escape to the JavaScript world. It reflects JavaScript’s overarching flexibility. Typescript is a compromise. This exception exists not due to some failure in design but the nature of not being the actual runtime language as the runtime language here is still JavaScript.the bottom of the tree#
The
never
type is the bottom for the tree, from which no further branches extend.Symmetrically, the
never
type behaves like the an anti-type of the top types -any
andunknow
, whereasany
andunknown
accept all values,never
doesn’t accept any value (including values of theany
type) at all since it is the subtype of all types.If you think hard enough, you might have realized that
never
should have an infinite amount of types and members, as it must be assignable or substitutable to its supertypes, i.e. every other type in the type system in TypeScript according to the Liskov substitution principle. For example, our program should behave correctly after we substitutenumber
andstring
withnever
sincenever
is the subtype of bothstring
andnumber
types and it shouldn’t break the behavior defined by its supertypes.Technically this is impossible to achieve. Instead, TypeScript makes
never
an empty type (a.k.a an uninhabitable type): a type for which we cannot have an actual value at runtime, nor can we do anything with the type e.g. accessing properties on its instances. The canonical usecase fornever
is when we want to type a return value from a function that never returns.The assignment above might seem wrong to you at first - if
never
is an empty type, why is that we can assign it to anumber
type? The reason why such an assignment is fine is that the compiler knows that our function never returns so nothing will ever be assigned to thenumber
variable. Types exist to ensure that the data is correct at runtime. If the assignment never actually happens at runtime, and the compiler knows that for sure in advance, then the types don’t matter.There is another way to produce a
never
type is to intersect two types that aren’t compatible - e.g.{x: number} & {x: string}
.types in between#
We have talked about the top types and the bottom type. The types in between are just the other regular types you use everyday -
number
,string
,boolean
, composite types likeobject
etc.There shouldn’t be too much surprise as to how those types work once we have established the correct mental model:
let stringLiteral: 'hello' = 'hello'
to astring
type (upcast) but not the other way around (downcast)However there is one type I want to talk more about in this section since people often confuse it with the bottom type
never
and that type isvoid
.In many other languages, such as C++,
void
is used as the a function return type that means that function doesn't return. However, in TypeScript, for a function that doesn't return at all, the correct type of the return value isnever
.So what is the type
void
in TypeScript?void
in TypeScript is a supertype ofundefined
- TypeScript allows you to assignundefined
tovoid
(upcaset) but again, not the other way around (downcast)This can also be verified via the
extends
keyword:In TypeScript, the type
void
is used to indicate that the implementer of a function is making no guarantees about the return type except that it won’t be useful to the callers. This opens the door for avoid
function at runtime to return something other thanundefined
, but whatever it returns shouldn’t be used by the caller.At first blush this might seem like a violation for the Liskov substitution principle since the type
string
is not a subtype ofvoid
so it shouldn’t be able to be substitutable forvoid
. However, if we view it from the perspective of whether or not it alters the correctness of the program, then it becomes apparent that as long as the caller function has no business with the returned value from thevoid
function (which is exactly the intended outcome of thevoid
type), it is pretty harmless to substitute that with a function that returns something different.This is where TypeScript is trying to be pragmatic and complements the way JavaScript works with functions. In JavaScript it is pretty common when we reuse functions in different situations with the return values being ignored.
Another cool tip about
void
type (credit to @simey) is that you can annotatethis
withvoid
when declaring a function:This prevents you from using
this
inside the function.situations where TypeScript disallows implicit upcast#
Generally there are two situations, and to be honest it should be pretty rare to find yourself in these situations:
Further Reading#
via zhenghao.io
March 26, 2022 at 04:07PM
The text was updated successfully, but these errors were encountered: