Skip to Content
Course content

112: Practice Exercise: Building a Type-Safe Router

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this a dozen times in code reviews: a developer decides to make their routing "type-safe" by creating a huge union of strings. It looks like this: type Route = '/home' | '/profile' | '/settings' | '/user/:id'. On the surface, it feels like you've solved the problem. You get autocomplete, and you can't accidentally type '/setttings' with three 't's.

The 'Union of Strings' Trap

The problem arrives the second you have dynamic parameters. If your route is '/user/:id', that string is a pattern, not an actual destination. When you actually call your navigation function, you aren't navigating to the literal string '/user/:id'; you're navigating to '/user/123' or '/user/abc'.

type Route = '/home' | '/user/:id';

function navigate(path: Route) {
  window.location.href = path;
}

// This works (but we probably didn't mean to go to the literal pattern)
navigate('/user/:id'); 

// This throws a TypeScript error, even though it's a perfectly valid URL
navigate('/user/123'); // Error: Argument of type '"/user/123"' is not assignable to parameter of type 'Route'.

This is a classic case of confusing a definition with an instance. Your union is defining the "shape" of the route, but TypeScript is treating it as the literal value. It's frustrating because the type system is technically doing exactly what you told it to do, but you told it the wrong thing.

Driving Type Safety from a Single Source of Truth

To fix this, we need to stop manually writing strings and start deriving our types from a configuration object. I prefer using a mapped type combined with TypeScript's template literal types. This allows us to define the parameters for each route once and have TypeScript calculate the resulting string patterns for us.

Instead of a union, let's define a RouteConfig. I like to use a record where the key is a friendly name for the route and the value defines the params it expects. If a route has no params, we can just use an empty object.

interface RouteConfig {
  home: {};
  userProfile: { userId: string };
  productDetail: { productId: string; category: string };
}

// Here is where the magic happens. 
// We create a helper that transforms our config into a type-safe function call.
type NavigateFn = {
  [K in keyof RouteConfig]: (params: RouteConfig[K]) => string;
};

const router: NavigateFn = {
  home: () => '/home',
  userProfile: ({ userId }) => `/user/${userId}`,
  productDetail: ({ productId, category }) => `/shop/${category}/${productId}`,
};

// Now, the compiler forces you to provide the correct params for the specific route
const url = router.userProfile({ userId: '42' }); // "/user/42"
// const error = router.userProfile({}); // Error: Property 'userId' is missing

By shifting the logic from "matching a string" to "calling a function based on a config," we've eliminated the possibility of a malformed URL. You aren't just checking if a string exists in a list; you're ensuring that the data required to build that string is present and correctly typed. It's a bit more boilerplate up front, but it saves you from those "undefined" or "NaN" segments in your URLs that usually only show up in production.




📋 Practical Task

Exercise: Implementing a Parametric Route Guard for an E-commerce Dashboard

You are building a dashboard for an e-commerce store. You need to implement a type-safe navigation system that prevents developers from navigating to product or order pages without providing the necessary IDs.

Requirements:

  • Create a DashboardRoutes interface that defines three routes:
    • overview (no parameters)
    • orderDetail (requires an orderId of type number)
    • customerSupport (requires a ticketId of type string and a priority of 'low' | 'high')
  • Implement a router object that maps these keys to functions returning the final URL string (e.g., /orders/123).
  • Create a function safeNavigate(routeName: keyof DashboardRoutes, params: any). Use a generic type constraint to ensure that params matches the requirements of the routeName provided.
  • Test your implementation by attempting to call safeNavigate with missing or incorrectly typed parameters to ensure the TypeScript compiler catches the errors.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.