Hone logo
Hone
Problems

Type-Safe Routing with TypeScript

Building robust web applications often involves managing various routes and handling incoming requests. A common challenge is ensuring that the parameters and payload expected by a particular route handler are correctly typed, preventing runtime errors and improving developer experience. This challenge focuses on creating a type-safe routing system in TypeScript.

Problem Description

Your task is to implement a simplified, type-safe routing system for an API. This system should allow defining routes with specific HTTP methods, URL patterns, and expected request/response types. The core requirement is to ensure that when a route is matched and a handler is invoked, the types of the request parameters, query parameters, and body are strictly enforced by TypeScript.

Key Requirements:

  • Route Definition: You need a way to define routes, associating them with an HTTP method (e.g., GET, POST), a URL pattern, and specific types for request parameters, query parameters, and the request body.
  • Type Safety: The system must guarantee that route handlers receive arguments with the correct types as defined for that route. This includes:
    • URL Parameters: Extracting and typing parameters from the URL (e.g., /users/:userId).
    • Query Parameters: Extracting and typing query parameters from the URL (e.g., /products?category=electronics).
    • Request Body: Typing the JSON body of requests (for methods like POST, PUT).
  • Request Matching: A mechanism to match incoming requests (defined by method and URL) to registered routes.
  • Handler Invocation: Executing the correct handler function for a matched route, passing the correctly typed parameters.
  • Response Typing (Optional but Recommended): While not strictly enforced by the matching logic, defining expected response types for routes would be a valuable addition.

Expected Behavior:

The system should act as a dispatcher. Given a request (method, URL, body), it should find the most appropriate route, extract relevant data, and call the associated handler with type-safe arguments.

Edge Cases to Consider:

  • Routes with no parameters.
  • Routes with optional parameters.
  • Routes with parameters of different primitive types (string, number, boolean).
  • Requests that do not match any defined route.
  • Requests with an unexpected request body format.

Examples

Let's define a simplified Request object and a Router class.

Example 1: Basic GET Route with URL Parameter

// Define the types for our route
interface GetUserParams {
  userId: number;
}

interface GetUserResponse {
  id: number;
  name: string;
}

// Assume a simple Request object structure
interface Request<P = {}, Q = {}, B = any> {
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  url: string;
  params: P;
  query: Q;
  body: B;
}

// Define the route handler
type RouteHandler<P, Q, B, R> = (
  req: Request<P, Q, B>
) => Promise<R>;

// --- In your Router implementation ---

// When defining a route for GET /users/:userId
// We want the handler to receive userId as a number
router.get<GetUserParams, {}, undefined, GetUserResponse>(
  '/users/:userId',
  async (req) => {
    const userId = req.params.userId; // TypeScript knows userId is a number
    console.log(`Fetching user with ID: ${userId}`);
    // Assume fetching user data...
    return { id: userId, name: 'Alice' };
  }
);

// --- Simulating a request ---
const simulatedRequest1: Request<{ userId: string }> = { // Note: raw URL param is string initially
  method: 'GET',
  url: '/users/123',
  params: { userId: '123' }, // Router will parse and convert if needed
  query: {},
  body: undefined,
};

// The router would internally parse '/users/123', match the route,
// extract '123' as the userId, convert it to a number based on the route definition,
// and call the handler.

// Expected behavior for the handler:
// Inside the handler, req.params.userId will be of type 'number'.
// console.log should output: Fetching user with ID: 123
// The returned value should conform to GetUserResponse.

Example 2: POST Route with Query Parameters and Request Body

// Define the types for our route
interface CreateProductQuery {
  category?: string; // Optional query parameter
}

interface CreateProductBody {
  name: string;
  price: number;
}

interface CreateProductResponse {
  id: string;
  name: string;
  price: number;
  category?: string;
}

// --- In your Router implementation ---

// When defining a route for POST /products
router.post<{}, CreateProductQuery, CreateProductBody, CreateProductResponse>(
  '/products',
  async (req) => {
    const category = req.query.category; // TypeScript knows category is string | undefined
    const { name, price } = req.body; // TypeScript knows name and price types

    console.log(`Creating product: ${name}, Price: ${price}, Category: ${category || 'Uncategorized'}`);

    // Assume creating product and generating an ID...
    const productId = `prod_${Math.random().toString(36).substring(7)}`;
    return { id: productId, name, price, category };
  }
);

// --- Simulating a request ---
const simulatedRequest2: Request<{}, CreateProductQuery, CreateProductBody> = {
  method: 'POST',
  url: '/products?category=electronics',
  params: {},
  query: { category: 'electronics' },
  body: { name: 'Laptop', price: 1200 },
};

// Expected behavior for the handler:
// Inside the handler, req.query.category is 'electronics' (string).
// req.body.name is 'Laptop' (string), req.body.price is 1200 (number).
// console.log should output: Creating product: Laptop, Price: 1200, Category: electronics
// The returned value should conform to CreateProductResponse.

Example 3: Route with Number and Boolean URL Parameters, and No Body

interface GetArticleParams {
  articleId: number;
  isPublished: boolean;
}

interface GetArticleResponse {
  id: number;
  title: string;
  published: boolean;
}

// --- In your Router implementation ---

// When defining a route for GET /articles/:articleId/:isPublished
// The router needs to infer or be told that :articleId is a number and :isPublished is a boolean.
router.get<GetArticleParams, {}, undefined, GetArticleResponse>(
  '/articles/:articleId/:isPublished',
  async (req) => {
    const { articleId, isPublished } = req.params; // TypeScript knows types
    console.log(`Fetching article: ${articleId}, Published: ${isPublished}`);
    // Assume fetching article data...
    return { id: articleId, title: 'TypeScript Routing', published: isPublished };
  }
);

// --- Simulating a request ---
const simulatedRequest3: Request<{ articleId: string, isPublished: string }> = { // Raw URL params are strings
  method: 'GET',
  url: '/articles/42/true',
  params: { articleId: '42', isPublished: 'true' }, // Router will parse and convert
  query: {},
  body: undefined,
};

// Expected behavior for the handler:
// Inside the handler, req.params.articleId will be 42 (number).
// req.params.isPublished will be true (boolean).
// console.log should output: Fetching article: 42, Published: true
// The returned value should conform to GetArticleResponse.

Constraints

  • The routing system should be implemented in TypeScript.
  • The URL patterns will use a simple syntax for parameters, e.g., /users/:userId, /products/:productId/:variant.
  • Parameter types (string, number, boolean) for URL parameters should be inferable or explicitly definable. The system should handle the conversion from string to the correct type.
  • Query parameters are expected to be strings or arrays of strings, but your type definitions should allow for optional parameters and specify their expected type (e.g., string | undefined).
  • Request bodies are assumed to be JSON.
  • The implementation should focus on type safety and correctness over high-performance routing (e.g., a simple linear scan for route matching is acceptable).

Notes

  • Consider how you will define the route signatures to allow for generic type parameters for params, query, body, and response.
  • Think about how to parse URL parameters from strings to their intended types (e.g., '123' to 123).
  • You'll likely need a way to map URL parameter names to their defined types.
  • A robust solution might involve a separate type or interface to describe the structure of a route definition.
  • Consider how to handle cases where a parameter name in the URL pattern might clash with a reserved word or a predefined key.
  • For this challenge, you can assume a simplified Request object structure. You don't need to implement a full HTTP server. The focus is on the routing and type-checking logic.
Loading editor...
typescript