Types describe sets of allowed values
Let’s imagine that types describe a set of allowed values that a value might be.
For example:
tsconst x: boolean
x could be either item from the following set {true, false}. Let’s look at another example:
tsconst y: number
y could be any number. If we wanted to get technical and express this in terms of set builder notation, this would be {y | y is a number}1
Let’s look at a few more, just for completeness:
tsTryleta : 5 | 6 | 7 // anything in { 5, 6, 7 }letb : null // anything in { null }letc : {favoriteFruit ?: "pineapple" // { "pineapple", undefined }}
Hopefully this makes sense. Now we are ready to continue…
Top types
A top type (symbol: ⊤) is a type that describes any possible value allowed by the system.
To use our set theory mental model, we could describe this as {x| x could be anything }
TypeScript provides two of these types: any and unknown.
any
You can think of values with an any type as “playing by the usual JavaScript rules”.
Here’s an illustrative example:
tsTryletflexible : any = 4flexible = "Download some more ram"flexible =window .document flexible =setTimeout
any typed values provide none of the safety we typically expect from TypeScript.
tsTryletflexible : any = 14flexible .it .is .possible .to .access .any .deep .property
It’s important to understand that any is not necessarily a problem — sometimes
it’s exactly the right type to use for a particular situation.
For example, console.log:
tsTryconsole .log (window ,Promise ,setTimeout , "foo")
We can see here that any is not always a “bug” or a “problem” — it just indicates maximal flexibility and the absence of type checking validation.
unknown
Like any, unknown can accept any value:
tsTryletflexible : unknown = 4flexible = "Download some more ram"flexible =window .document flexible =setTimeout
However, unknown is different from any in a very important way:
Values with an
unknowntype cannot be used without first applying a type guard
tsTryletmyUnknown : unknown = 14'myUnknown' is of type 'unknown'.18046'myUnknown' is of type 'unknown'.myUnknown .it .is .possible .to .access .any .deep .property // This code runs for { myUnknown| anything }if (typeofmyUnknown === "string") {// This code runs for { myUnknown| all strings }console .log (myUnknown , "is a string")} else if (typeofmyUnknown === "number") {// This code runs for { myUnknown| all numbers }console .log (myUnknown , "is a number")} else {// this would run for "the leftovers"// { myUnknown| anything except string or numbers }}
Practical use of top types
You will run into places where top types come in handy very often. In particular,
if you ever convert a project from JavaScript to TypeScript, it’s very convenient
to be able to incrementally add increasingly strong types. A lot of things will
be any until you get a chance to give them some attention.
unknown is great for values received at runtime (e.g., your data layer). By
obligating consumers of these values to perform some light validation before using them,
errors are caught earlier, and can often be surfaced with more context.
Bottom type: never
A bottom type (symbol: ⊥) is a type that describes no possible value allowed by the system.
To use our set theory mental model, we could describe this as “any value from the following set: { } (intentionally empty)”
TypeScript provides one bottom type: never.
At first glance, this may appear to be an extremely abstract and pointless concept, but there’s one use case that should convince you otherwise. Let’s take a look at this scenario below.
Exhaustive conditionals
Let’s consider the following scenario:
tsTryclassCar {drive () {console .log ("vroom")}}classTruck {tow () {console .log ("dragging something")}}typeVehicle =Truck |Car letmyVehicle :Vehicle =obtainRandomVehicle ()// The exhaustive conditionalif (myVehicle instanceofTruck ) {myVehicle .tow () // Truck} else if (myVehicle instanceofCar ) {myVehicle .drive () // Car} else {// NEITHER!constneverValue : never =myVehicle }
Now, leaving the conditional exactly as-is, let’s add Boat as a vehicle type:
tsTryclassCar {drive () {console .log ("vroom")}}classTruck {tow () {console .log ("dragging something")}}classBoat {isFloating () {return true}}typeVehicle =Truck |Car |Boat letmyVehicle :Vehicle =obtainRandomVehicle ()// The exhaustive conditionalif (myVehicle instanceofTruck ) {myVehicle .tow () // Truck} else if (myVehicle instanceofCar ) {myVehicle .drive () // Car} else {// NEITHER!constType 'Boat' is not assignable to type 'never'.2322Type 'Boat' is not assignable to type 'never'.: never = neverValue myVehicle }
Effectively, what has happened here is that we have been alerted to the fact that
a new possibility for Vehicle has been introduced. As a result, we don’t
end up with the type for myVehicle as a never in that final else clause.
I recommend handling this a little more gracefully via an error subclass:
tsTryclassUnreachableError extendsError {constructor(_nvr : never,message : string) {super(message )}}// The exhaustive conditionalif (myVehicle instanceofTruck ) {myVehicle .tow () // Truck} else if (myVehicle instanceofCar ) {myVehicle .drive () // Car} else {// NEITHER!throw newUnreachableError (Argument of type 'Boat' is not assignable to parameter of type 'never'.2345Argument of type 'Boat' is not assignable to parameter of type 'never'., myVehicle `Unexpected vehicle type: ${myVehicle }`)}
Now, one of three things will happen in that final else block
- We will have handled every case before reaching it, and thus we will never enter the final
elseblock - We will catch upstream code changes that need to be handled in this conditional at compile time (e.g., adding the
Boatcase) - If somehow an unexpected value “slip through” and is not caught until we actually run the code, we will get a meaningful error message
Note that this approach works nicely with a switch statement, when the UnreachableError is thrown from the default case clause.
-
Technically in JS or TS this would be
↩{ y| -Number.MAX_VALUE <= y <= Number.MAX_VALUE }, but if you know enough to ask, you probably don’t need this footnote…!