Learn TypeScript w/ Mike North

Top and bottom types

October 23, 2023

Table of Contents

Type systems often have types representing the largest and smallest possible sets of values. These are called top and bottom types.

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 representing the set { any possible value }

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 a need to type-check before using the value.

unknown

Like any, unknown can accept any value that is possible to create in JavaScript:

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

Sometimes people refer to this property of unknown by describing it as “opaque”.

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 = { all possible values }
if (typeof myUnknown === "string") {
// This code runs for myUnknown = { all strings }
myUnknown
let myUnknown: string
} else if (typeof myUnknown === "number") {
// This code runs for myUnknown = { all numbers }
myUnknown
let myUnknown: number
} else {
myUnknown
let myUnknown: unknown
// this would run for "the leftovers"
// myUnknown = { anything except string or numbers }
}
Try

TypeScript doesn’t (yet) have the ability to articulate a concept like “anything except a number or a string”, so you may notice that the else { } block at the end still has myUnknown as type unknown.

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.

Another wise use of unknown is handling throwables in a catch block

ts
function doSomethingRisky() {
if (Math.random() > 0.5) return "ok"
else if (Math.random() > 0.5) throw new Error("Bad luck!")
else throw "Really bad luck"
}
 
try {
doSomethingRisky()
} catch (e: unknown) {
if (e instanceof Error) {
e
var e: Error
} else if (typeof e === 'string') {
e
var e: string
} else {
// Last resort
console.error(e)
var e: unknown
}
}
Try

It’s a good practice to always throw Error instances, but how sure are you that everything in your node_modules folder does the same? By typing the argument caught by the catch block as unknown, you’re effectively forcing yourself to handle these values in a more robust way.

There’s a compiler flag useUnknownInCatchVariables that helps enforce this across a project, without requiring any explicit type annotation.

ts
function doSomethingRisky() {
if (Math.random() > 0.5) return "ok"
else if (Math.random() > 0.5) throw new Error("Bad luck!")
else throw "Really bad luck"
}
 
try {
doSomethingRisky()
} catch (err) {
var err: unknown
}
Try

Almost top type: object

The object type represents the set { all possible values except for primitives }. Primitive value types in JavaScript are { string, number, boolean, Symbol, null, undefined, BigInt }

It’s important to understand that this is not quite the same concept of the “object types” term used to describe shapes that interfaces can model.

ts
let val: object = { status: "ok" }
val = "foo"
Type 'string' is not assignable to type 'object'.2322Type 'string' is not assignable to type 'object'.
val = null
Type 'null' is not assignable to type 'object'.2322Type 'null' is not assignable to type 'object'.
val = () => "ok"
 
// The type of this value cannot be modeled by an interface
let response:
let response: { success: string; data: unknown; } | { error: string; code: number; }
{ success: string, data: unknown }
| { error: string, code: number }
= { success: "ok", data: [] }
 
val = response
Try

Almost top type: {}

The empty object type {} represents the set { all possible values, except for null and undefined }

ts
const stringOrNumber: string | number = 4
let nullableString: string | null = null
const myObj: {
a?: number
b: string
} = { b: "foo" }
 
 
let val2: {} = 4
val2 = "abc"
val2 = new Date()
val2 = stringOrNumber
val2 = nullableString
Type 'null' is not assignable to type '{}'.2322Type 'null' is not assignable to type '{}'.
val2 = myObj.a
Type 'number | undefined' is not assignable to type '{}'. Type 'undefined' is not assignable to type '{}'.2322Type 'number | undefined' is not assignable to type '{}'. Type 'undefined' is not assignable to type '{}'.
Try

Based on what we’re seeing here, {} | null | undefined is technically another top type, since now we’re back to a set of { all possible values }

ts
let withoutUndefined: {} | null = 37
let withUndefined: {} | null | undefined = 38
let anUnknown: unknown = "42"
 
 
withoutUndefined = anUnknown // ❌
Type 'unknown' is not assignable to type '{} | null'.2322Type 'unknown' is not assignable to type '{} | null'.
withUndefined = anUnknown // ✅
Try

You can use the type {} in combination with the intersection type operator & to remove nullability from another type

ts
type NullableStringOrNumber = string | number | null | undefined;
type StringOrNumber = NullableStringOrNumber & {}
type StringOrNumber = string | number
Try

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 a type representing the 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

Note the assignment in the last else { } block. This will only work if the type of myVehicle is type equivalent to never.

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.

Unit types

Unit types are types that represent a set of exactly one value. An example of this is a literal type

ts
let num: 65 = 65 // represents the set { 65 }
let num: 65
Try

Nothing other than the specific value 65 will work with this type.

In TypeScript, the types null and undefined are both unit types.

ts
let myNull: null = null
let myUndefined: undefined = undefined
 
 
myNull = undefined
Type 'undefined' is not assignable to type 'null'.2322Type 'undefined' is not assignable to type 'null'.
 
myUndefined = null
Type 'null' is not assignable to type 'undefined'.2322Type 'null' is not assignable to type 'undefined'.
Try

the void type is almost a unit type, but it can check against undefined as well

ts
let myVoid: void = (function() {})()// invoking a void-returning IIFE
let myNull: null = null
let myUndefined: undefined = undefined
 
myVoid = undefined
myVoid = null
Type 'null' is not assignable to type 'void'.2322Type 'null' is not assignable to type 'void'.
 
myUndefined = myVoid
Type 'void' is not assignable to type 'undefined'.2322Type 'void' is not assignable to type 'undefined'.
myNull = myVoid
Type 'void' is not assignable to type 'null'.2322Type 'void' is not assignable to type 'null'.
Try


© 2023 All Rights Reserved