🚀 Executive Summary

TL;DR: Type-safe routing in TypeScript without codegen addresses the compile-time vs. runtime divide, where string-based URL parameters lack inherent type guarantees. The solution involves either a ‘schema-first’ approach using libraries like Zod for runtime validation and type inference, or a ‘shared contract’ for disciplined, consistent type definitions across frontend and backend.

🎯 Key Takeaways

  • TypeScript’s compile-time checks do not inherently guarantee the existence or type correctness of runtime URL parameters, which are string-based.
  • The ‘Schema-First’ approach (e.g., Zod) defines a runtime validation schema from which static TypeScript types are inferred, providing both runtime safety and compile-time type checking.
  • The ‘Shared Contract’ approach uses a central file to define routes and their types, ensuring consistency between frontend and backend without new dependencies, but requires strict discipline and lacks inherent runtime validation.
  • Relying solely on TypeScript’s template literal types or generics without runtime validation (the ‘Live Dangerously’ method) creates a brittle system prone to human error and offers no protection against malformed requests.
  • Choosing a codegen-free solution involves balancing the need for runtime safety, developer experience, and the overhead of dependencies versus the discipline required for maintaining type consistency.

Why does a router need codegen for type safety? I built one that doesn't

Type-safe routing in TypeScript without codegen isn’t magic; it’s a strategic choice between runtime validation with type inference (like Zod) and disciplined, shared contracts. This post breaks down why this problem exists and how to solve it for good.

So, You Built a Type-Safe Router Without Codegen. Let’s Talk.

I remember the PagerDuty alert like it was yesterday. 2:17 AM. A massive spike in 500 errors from our main `api-gateway-v3` service. I roll out of bed, squint at the logs, and see it: `TypeError: Cannot read properties of undefined (reading ‘userId’)`. A junior engineer had pushed a “simple” frontend change. They’d updated a route from /users/:id/profile to /users/:userId/profile. The backend router, blissfully unaware of this string change, was still expecting `req.params.id`. Boom. The entire user settings page was down. For everyone.

That’s the ghost in the machine. That’s why we have this debate. When someone on Reddit says, “Why does a router need codegen for type safety? I built one that doesn’t,” they aren’t wrong, but they’re often missing the scars that lead us to these “over-engineered” solutions. It’s not about whether you *can* build it without codegen; it’s about what happens at 2 AM when human error inevitably strikes.

The Core of the Problem: The Compile-Time vs. Runtime Divide

Let’s get one thing straight: TypeScript is a compile-time tool. It checks your code for errors *before* it ever becomes JavaScript. Your router, however, is a runtime beast. It lives in the land of JavaScript, processing real HTTP requests with string-based paths like /api/v1/reports/quarterly.

The fundamental disconnect is this: How does your compile-time TypeScript code *know*, with 100% certainty, that the runtime URL param named :reportId will actually exist on the request object? It can’t. Not on its own. It’s just a string. This is where the danger lies and why we reach for patterns to bridge this gap.

You either need to:

  • Generate types from a single source of truth (the “codegen” approach).
  • Infer types from a runtime schema that doubles as a validator.
  • Manually assert types and pray you never make a typo (the “trust me, bro” approach).

Let’s look at how we solve this in the real world, without firing up a complex code generation pipeline.

Solution 1: The ‘Schema-First’ Approach (My Go-To)

This is the modern, battle-tested way. Instead of codegen, we use a library like Zod, Valibot, or Yup to define a schema that validates the incoming request at runtime. The magic is that these libraries are built with TypeScript, so we can *infer* a static type directly from the runtime schema. You get both runtime safety and compile-time autocomplete.

You define what you expect, validate the messy real world against it, and proceed with perfect type safety.

Example with Zod and Express:


import { z } from 'zod';
import { Request, Response } from 'express';

// 1. Define the schema for the route's inputs
const GetUserParams = z.object({
  // It validates that userId is a string that can be parsed as a number
  userId: z.string().pipe(z.coerce.number()), 
});

// We can infer the TypeScript type directly from the schema!
type UserParams = z.infer<typeof GetUserParams>;

// 2. The actual route handler
app.get('/api/users/:userId', (req: Request<UserParams>, res: Response) => {
  try {
    // 3. Runtime validation
    const { userId } = GetUserParams.parse(req.params);

    // From this point on, TypeScript KNOWS `userId` is a number.
    // No more `req.params.userId as string` or other unsafe assertions.
    console.log(`Fetching data for user ID: ${userId}`);
    
    // const user = await db.users.find({ id: userId });
    res.json({ id: userId, name: 'Darian Vance' });

  } catch (error) {
    // If validation fails, Zod throws an error we can catch
    res.status(400).json({ error: 'Invalid user ID provided.' });
  }
});

Pro Tip: This approach is fantastic because your validation logic and your types are derived from the exact same source. It’s virtually impossible for them to drift out of sync. This has saved my teams countless hours of debugging.

Solution 2: The ‘Shared Contract’ Approach

If you don’t want to add a dependency like Zod, you can go the “pure TypeScript” route. This involves creating a central file or package—a “contract”—that defines the routes and their associated types. Your backend router implements this contract, and your frontend client consumes it. It requires more discipline but is very powerful.

Example of a Shared Contract:

Imagine a shared file, maybe in an `our-app-contracts` npm package.


// In: packages/contracts/src/api-routes.ts

export const API_ROUTES = {
  getUser: {
    path: (userId: number) => `/api/users/${userId}`,
    method: 'GET',
    // We can even define response types!
    response: {} as { id: number; name: string; email: string }, 
  },
  updateUser: {
    path: (userId: number) => `/api/users/${userId}`,
    method: 'PATCH',
    body: {} as { name?: string; email?: string },
  },
};

Your frontend would then use this to build URLs, ensuring it can’t make a typo. Your backend would use it to define routes. The weakness here is that it doesn’t automatically validate runtime data; it just ensures your TypeScript code on both sides of the wire is using the same shapes and paths.

Solution 3: The ‘Live Dangerously’ Approach (aka The Original Poster’s Method)

This is what the Reddit poster likely did. They created a clever router that uses TypeScript’s template literal types or generics to provide a decent developer experience, but it often lacks true runtime validation. It feels safe in your editor, but it offers zero protection from a malformed request from a curl command, a third-party webhook, or a simple frontend bug.

It typically looks something like this:


// This is a simplified conceptual example
interface RequestWithParams<T> extends Request {
  params: T;
}

// In the router's implementation...
// It feels type-safe here, but nothing is actually validating req.params
app.get('/users/:userId', (req: RequestWithParams<{ userId: string }>, res: Response) => {
  // You are TRUSTING that `userId` is present and is a string.
  const { userId } = req.params; 
  // What if the route was called with `/users/`? `userId` would be undefined.
  const idAsNumber = parseInt(userId, 10); // This will return NaN and cause chaos
  
  // ...
});

This method is fine for a solo project or a small, disciplined team. But it doesn’t scale. It puts the entire burden of correctness on the developers, with no safety net. One sleepy Monday morning, someone will refactor a route path and forget to update the manual type, and you’re back to my 2 AM PagerDuty alert.

Which Should You Choose?

Here’s how I break it down for my teams at TechResolve.

Approach Pros Cons
1. Schema-First (Zod)
  • True runtime safety
  • Types can’t drift from validators
  • Excellent developer experience
  • Adds a dependency
  • Slight runtime overhead for validation
  • 2. Shared Contract
  • No new dependencies
  • Single source of truth for paths
  • Enforces consistency
  • Requires team discipline
  • Doesn’t validate runtime values itself
  • Can be cumbersome to manage
  • 3. Live Dangerously
  • Simple, no overhead
  • Feels “clever” and lightweight
  • No runtime safety
  • Brittle and prone to human error
  • Doesn’t scale to larger teams
  • At the end of the day, building a router without codegen is not only possible, it’s the preferred method for many modern stacks. But doing it without a robust strategy for bridging the runtime/compile-time divide is just asking for a 2 AM wake-up call. And I, for one, need my sleep.

    Darian Vance - Lead Cloud Architect

    Darian Vance

    Lead Cloud Architect & DevOps Strategist

    With over 12 years in system architecture and automation, Darian specializes in simplifying complex cloud infrastructures. An advocate for open-source solutions, he founded TechResolve to provide engineers with actionable, battle-tested troubleshooting guides and robust software alternatives.


    🤖 Frequently Asked Questions

    âť“ Why is type safety in routers a problem without codegen?

    TypeScript operates at compile-time, while routers process runtime string-based HTTP requests. Without codegen, there’s a fundamental disconnect, making it challenging for compile-time types to guarantee the existence and correct type of runtime URL parameters, leading to potential `TypeError` at 2 AM.

    âť“ How does this compare to alternatives?

    Compared to complex codegen pipelines, these methods offer lighter-weight alternatives. The ‘schema-first’ approach (e.g., Zod) provides robust runtime validation and type inference, which codegen typically doesn’t include. The ‘shared contract’ approach achieves a single source of truth for paths and types, similar to codegen’s goal, but requires manual runtime validation or additional tooling.

    âť“ Common implementation pitfall?

    A common pitfall is implementing type safety using only TypeScript’s type assertions or template literal types without corresponding runtime validation. This creates a false sense of security, as a malformed request or a simple typo in a route path can still lead to `TypeError` at runtime, despite compile-time checks appearing to pass. The solution is to integrate a runtime validator like Zod.

    Leave a Reply

    Discover more from TechResolve - SaaS Troubleshooting & Software Alternatives

    Subscribe now to keep reading and get access to the full archive.

    Continue reading