Learn TypeScript w/ Mike North

Top and bottom types

June 08, 2021

Table of Contents

Types describe sets of allowed values

Let’s imagine that types describe a set of allowed values that a value might be.

For example:

ts
const x: boolean

x could be either item from the following set {true, false}. Let’s look at another example:

ts
const 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:

ts
let a: 5 | 6 | 7 // anything in { 5, 6, 7 }
let b: null // anything in { null }
let c: {
favoriteFruit?: "pineapple" // { "pineapple", undefined }
(property) favoriteFruit?: "pineapple" | undefined
}
Try

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:

ts
let flexible: any = 4
flexible = "Download some more ram"
flexible = window.document
flexible = setTimeout
Try

any typed values provide none of the safety we typically expect from TypeScript.

ts
let flexible: any = 14
flexible.it.is.possible.to.access.any.deep.property
any
Try

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:

ts
console.log(window, Promise, setTimeout, "foo")
(method) Console.log(...data: any[]): void
Try

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:

ts
let flexible: unknown = 4
flexible = "Download some more ram"
flexible = window.document
flexible = setTimeout
Try

However, unknown is different from any in a very important way:

Values with an unknown type cannot be used without first applying a type guard

ts
let myUnknown: unknown = 14
myUnknown.it.is.possible.to.access.any.deep.property
'myUnknown' is of type 'unknown'.18046'myUnknown' is of type 'unknown'.
any
 
// This code runs for { myUnknown| anything }
if (typeof myUnknown === "string") {
// This code runs for { myUnknown| all strings }
console.log(myUnknown, "is a string")
let myUnknown: string
} else if (typeof myUnknown === "number") {
// This code runs for { myUnknown| all numbers }
console.log(myUnknown, "is a number")
let myUnknown: number
} else {
// this would run for "the leftovers"
// { myUnknown| anything except string or numbers }
}
Try

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:

ts
class Car {
drive() {
console.log("vroom")
}
}
class Truck {
tow() {
console.log("dragging something")
}
}
type Vehicle = Truck | Car
 
let myVehicle: Vehicle = obtainRandomVehicle()
 
// The exhaustive conditional
if (myVehicle instanceof Truck) {
myVehicle.tow() // Truck
} else if (myVehicle instanceof Car) {
myVehicle.drive() // Car
} else {
// NEITHER!
const neverValue: never = myVehicle
}
Try

Now, leaving the conditional exactly as-is, let’s add Boat as a vehicle type:

ts
class Car {
drive() {
console.log("vroom")
}
}
class Truck {
tow() {
console.log("dragging something")
}
}
class Boat {
isFloating() {
return true
}
}
type Vehicle = Truck | Car | Boat
 
let myVehicle: Vehicle = obtainRandomVehicle()
 
// The exhaustive conditional
if (myVehicle instanceof Truck) {
myVehicle.tow() // Truck
} else if (myVehicle instanceof Car) {
myVehicle.drive() // Car
} else {
// NEITHER!
const neverValue: never = myVehicle
Type 'Boat' is not assignable to type 'never'.2322Type 'Boat' is not assignable to type 'never'.
}
Try

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:

ts
class UnreachableError extends Error {
constructor(_nvr: never, message: string) {
super(message)
}
}
 
// The exhaustive conditional
if (myVehicle instanceof Truck) {
myVehicle.tow() // Truck
} else if (myVehicle instanceof Car) {
myVehicle.drive() // Car
} else {
// NEITHER!
throw new UnreachableError(
myVehicle,
Argument of type 'Boat' is not assignable to parameter of type 'never'.2345Argument of type 'Boat' is not assignable to parameter of type 'never'.
`Unexpected vehicle type: ${myVehicle}`
)
}
Try

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 else block
  • We will catch upstream code changes that need to be handled in this conditional at compile time (e.g., adding the Boat case)
  • 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.


  1. 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…!



© 2023 All Rights Reserved