Skip to content

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

  • E is the event map
  • K extends keyof E ensures only valid event names
  • handlers must accept the correct payload type
  • emit enforces 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.