- 1.6k words
- 8 mins
TypeScript is famous for its complex types and what they allow framework authors to achieve.
I'd like to introduce a different way to think about complex types: as type-level code, code that happens to execute during compilation.
We'll contrast it with runtime code – the normal code that gets stuff done.
Runtime code has strings, numbers, and objects as values; meanwhile, in type-level code, the values are types themselves.
In type-level code, we:
- Apply type-level operators.
- Call type-level functions (these are simply generic types).
- Define type-level interfaces, which we enforce using generic constraints.
- Make use of type-level data structures.
In this post, I’d like to focus on type-level data structures, and specifically type-level maps. They’re a great example of what type-level code really means.
What is a type-level map?
A type-level map is just an object type. We typically define it using an interface:
interface TypeLevelMap {
key1: MyType;
key2: MyOtherType;
key3: true;
key4: object;
}
While interfaces usually represents the shape of a runtime object, in our case one like this:
const a = {
key1: { name: "MyType" },
key2: { name: "MyOtherType" },
key3: true,
key4: new Date()
}
When we’re looking at it as a type-level map, it becomes an immutable dictionary. In this dictionary, types are values and the keys are strings.
Here’s how we can visualize it:
That’s a nice diagram, but how do we use them?
Working with type-level maps
To justify the shift to “type-level maps”, we need to find operations on object types that mimic how we might work with maps in runtime code. That means:
- Listing keys
- Looking up values by key
- Merging two maps
- Transforming a map
If we were doing math, we’d call this an isomorphism between dictionaries and object types. That means we can regard one as the other.
Listing keys
We can list the keys in a type-level map using the keyof
operator:
type Map = { key1: 42; key2: 123 }
type Keys = keyof Map // "key1" | "key2"
The union type we get is really a type-level set. But that’s outside the scope of this post.
Instead, lets compare it to listing keys on a JavaScript object:
const map = { key1: 42, key2: 123 }
const result = Object.keys(map) // ["key1", "key2"]
Lookups
We look up values by key using TypeScript’s lookup types:
type Map = { key: 42 }
type Result = Map["key"] // 42
That example shows what I like to call a static lookup. We can also do a generic lookup based on a type parameter, like this:
type MapValueOfKey<Key extends keyof Map> = Map[Key]
We can compare this to the same lookup in JavaScript:
const map = { key: 42 }
const result = map["key"] // 42
TypeScript has an extra feature, though – you can look up lots of values at once!
Multi-value lookups
TypeScript lets us look up more than one value by passing a bunch of keys:
type Map = { a: 1; b: 2; c: 3 }
type AB = Map["a" | "b"] // 1 | 2
Listing values
Taken to its logical conclusion, if we pass all the keys, we’ll get all the values:
type Map = { a: 1; b: 2; c: 3 }
type Vals = Map[keyof Map] // 1 | 2 | 3
Transforming
Since type-level maps are immutable, we can’t change them like we would a JavaScript dictionary. But we can still transform one map into another map.
We do this using a mapped type:
type Map = { key1: 42; key2: 123 }
type Result = {
[Key in keyof Map]: `${Map[Key]}`;
} // {key1: "42"; key2: "123"}
The closest JavaScript equivalent is the mapValues
function from lodash
:
import { mapValues } from "lodash"
const map = { key1: 42, key2: 123 }
const result = mapValues(map, x => `${x}`) // {key1: "42", key2: "123"}
Next, we’ll take a look at a use case.
Use case: handling events
Type-level maps have lots of use cases. One of the most common ones is handling events. In fact, you might’ve encountered it yourself.
Let’s say we have a Button
object that has a bunch of events. Every event comes with its own information object:
click
says which button the user clicked, either"left"
or"right"
.hover
gives the of the pointer.mount
doesn’t have any special information.
As is customary, we manage events using three main methods:
on
defines a handler for an event.off
removes a handler.emit
emits an event together with an information object.
There are a few ways we can implement this pattern at the type level.
Approach A: loosely typed
We could define all three methods with no type information. Like this:
export type Handler = (info: object) => void
declare class Button {
emit(name: string, info: object): void
on(name: string, handler: Handler): void
off(handler: Handler): void
}
let button = new Button()
button.emit("clikc", {
button: 1
})
This works, but we’re not type checking anything, introducing the possibility of sneaky bugs. Ideally, we’d like TypeScript to check event names and info objects, as well as handler signatures.
Let’s look at a second approach.
Approach B: hand-coding everything
One way to achieve that is to hand-code an overload signature for each method and every type of event, like this:
type ClickEventInfo = { button: "left" | "right" }
type HoverEventInfo = { x: number; y: number }
type Handler<Info> = (info: Info) => void
declare class Button {
// click events:
emit(name: "click", info: ClickEventInfo): void
on(name: "click", handler: Handler<ClickEventInfo>): void
off(handler: Handler<ClickEventInfo>): void
// hover events:
emit(name: "hover", info: HoverEventInfo): void
// ...
}
This solution shows that many of the problems we meet in runtime code are also present in type-level code. In this case, we’re not DRY – we keep repeating ourselves.
That makes expanding the Button
with more events time consuming and error-prone, and it also means we can’t easily extend the event infrastructure to cover other types of elements.
We can compare this approach to copy-pasting the runtime code for registering an event handler:
class Button {
_clickHandlers = []
_hoverHandlers = []
_mountHandlers = []
on(name, handler) {
if (name === "click") {
this._clickHandlers.push(handler)
}
if (name === "hover") {
this._hoverHandlers.push(handler)
}
if (name === "mount") {
this._hoverHandlers.push(handler)
}
}
}
In runtime code, the solution is pretty obvious – just use a Map.
class Button {
_handlers = new Map()
on(name, handler) {
let existing = this._handlers.get(name)
if (!existing) {
existing = new Set()
this._handlers.set(name, existing)
}
existing.add(handler)
}
}
We can do the same thing with our type-level code!
Approach C: using a type-level map
We start by defining a type-level map that maps every event name to its information object:
export interface ButtonEventMap {
click: ClickEventInfo
hover: HoverEventInfo
mount: {}
}
This type-level map also keeps track of all the valid event names.
We can then use local utility types, which are like variables in type-level code, to store the results of operations on the map:
// Get the map's keys:
type ButtonEventNames = keyof ButtonEventMap
// Transform each key to create a map of handlers:
type ButtonEventHandlerMap = {
[Name in ButtonEventNames]: Handler<
ButtonEventMap[Name]
>
}
Finally, we can declare the Button
class itself, using our utility types together with the map operations we talked about earlier:
declare class Button {
// A generic lookup to get a handler:
on<Name extends ButtonEventNames>(
name: Name,
handler: ButtonEventHandlerMap[Name]
): void
// A generic lookup to get the info object:
emit<Name extends ButtonEventNames>(
name: Name,
info: ButtonEventMap[Name]
): void
// Allow only valid handlers by listing values:
off(handler: ButtonEventHandlerMap[ButtonEventNames]): void
}
Looks interesting, but let’s make sure it actually works!
Testing it
First, we create a button:
const button = new Button()
We’ll define some handlers to see it compiles:
button.on("click", obj => {
console.log(`User clicked the ${obj.button} button!`)
})
button.on("hover", obj => {
console.log(`Mouse pointer is in (${obj.x}, ${obj.y})!`)
})
But the whole point was to catch typos and stuff like that. Let’s check that works too:
// @ts-expect-error clikc is not a valid event
button.on("clikc", obj => {})
// @ts-expect-error Missing property 'y'
button.emit("hover", {
x: 1
})
You can try it out yourself in this playground link!
Conclusion
Type-level code is another way of looking at type declarations. This approach lets us explain complexity using the same principles and tools we’ve learned to deal with runtime code.
In this post, we’ve taken a look at type-level maps. We saw how it corresponds to a runtime dictionary, and how using one can solve design issues at the type level.
I hope you’ll join me in exploring these concepts in the future!