Hands‑on: Build a type‑safe router or event emitter¶
Option A — Build a Type‑Safe Event Emitter¶
We’ll create an event system where:
- event names are strongly typed
- payloads are strongly typed
- handlers must match the payload type
- emitting an event enforces the correct payload
This is a perfect demonstration of template literal types + inference.
Step 1 — Define the Event Map¶
type Events = {
"user:login": { userId: string }
"user:logout": { userId: string }
"order:created": { orderId: string; amount: number }
}
This is the source of truth for all event names and payloads.
Step 2 — Create a Type‑Safe Event Emitter¶
class EventEmitter<E extends Record<string, any>> {
private listeners: {
[K in keyof E]?: Array<(payload: E[K]) => void>
} = {}
on<K extends keyof E>(event: K, handler: (payload: E[K]) => void) {
(this.listeners[event] ??= []).push(handler)
}
emit<K extends keyof E>(event: K, payload: E[K]) {
this.listeners[event]?.forEach(handler => handler(payload))
}
}
What’s happening¶
Eis the event mapK extends keyof Eensures only valid event names- handlers must accept the correct payload type
emitenforces the correct payload
This is full type safety.
Step 3 — Use the Typed Event Emitter¶
const emitter = new EventEmitter<Events>()
emitter.on("user:login", payload => {
console.log(payload.userId) // payload is { userId: string }
})
emitter.emit("user:login", { userId: "123" }) // ✔ OK
Invalid usage is caught at compile time¶
emitter.emit("user:login", { id: "123" })
// ❌ Error: missing userId
emitter.on("unknown:event", () => {})
// ❌ Error: event name not allowed
This is the power of TypeScript’s type system.
Step 4 — Add Template Literal Event Names (Optional)¶
Let’s enforce a naming convention:
type Domain = "user" | "order"
type Action = "login" | "logout" | "created"
type EventName = `${Domain}:${Action}`
Now only strings like "user:login" are allowed.
Step 5 — Infer Event Names from Payloads (Advanced)¶
type EventNameFromPayload<E, P> = {
[K in keyof E]: E[K] extends P ? K : never
}[keyof E]
Usage:
type LoginEvent = EventNameFromPayload<Events, { userId: string }>
// "user:login" | "user:logout"
This is how advanced event systems infer event names from payloads.
Option B — Build a Type‑Safe Router¶
We’ll build a router where:
- route paths are typed
- route parameters are extracted
- handlers receive typed params
- calling the router enforces correct arguments
This is similar to what Next.js, Remix, and Fastify do internally.
Step 1 — Define Route Patterns¶
type Routes = {
"/users": void
"/users/:id": { id: string }
"/orders/:orderId/items/:itemId": { orderId: string; itemId: string }
}
Each route maps to the type of its parameters.
Step 2 — Extract Params from a Route Pattern¶
type ExtractParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<Rest>]: string }
: Path extends `${string}:${infer Param}`
? { [K in Param]: string }
: {}
This recursively extracts :id segments.
Step 3 — Create a Typed Router¶
class Router<R extends Record<string, any>> {
private handlers: {
[K in keyof R]?: (params: R[K]) => void
} = {}
on<K extends keyof R>(path: K, handler: (params: R[K]) => void) {
this.handlers[path] = handler
}
navigate<K extends keyof R>(path: K, params: R[K]) {
this.handlers[path]?.(params)
}
}
Step 4 — Use the Typed Router¶
const router = new Router<Routes>()
router.on("/users/:id", params => {
console.log(params.id) // typed as string
})
router.navigate("/users/:id", { id: "123" }) // ✔ OK
Invalid usage is caught¶
router.navigate("/users/:id", { userId: "123" })
// ❌ Error: wrong param name
router.navigate("/unknown", {})
// ❌ Error: route not defined
Step 5 — Generate Route Types Automatically (Bonus)¶
You can generate the Routes type from a list of paths:
type Paths = "/users" | "/users/:id" | "/orders/:orderId/items/:itemId"
type RoutesFromPaths = {
[P in Paths]: ExtractParams<P>
}
Now the router is fully typed from a simple union of strings.
What You Learned¶
This hands‑on exercise demonstrates how to combine advanced TypeScript features to build real‑world systems:
1. Template literal types¶
- typed event names
- typed route patterns
- enforcing naming conventions
2. Mapped types¶
- mapping event names to payloads
- mapping route paths to parameter types
3. Conditional types¶
- extracting parameters
- filtering event names
- inferring payload types
4. infer¶
- extracting types from patterns
- building reusable utilities
5. Advanced inference¶
- handlers infer payload types
- routes infer parameter types
This is the kind of type‑level engineering that powers modern TypeScript frameworks.