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).
- URL Parameters: Extracting and typing parameters from the URL (e.g.,
- 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'to123). - 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
Requestobject structure. You don't need to implement a full HTTP server. The focus is on the routing and type-checking logic.