Type-Safe Routing in Modern TypeScript APIs
How to leverage TypeScript's type system for fully type-safe API routing in Hono, eliminating runtime errors through compile-time validation of routes, parameters, and responses.
Runtime errors in API routing are insidious. A misspelled parameter name, a missing path segment, or an incorrect response shape -- these bugs slip through testing because they only manifest under specific request conditions. In a gateway like Dispatch, where routing logic governs traffic for dozens of upstream services, a routing bug can cascade across your entire platform. TypeScript's type system, combined with Hono's type-aware routing, can eliminate these errors entirely at compile time. This article shows how.
The Problem with Untyped Routing
Consider a typical Express-style route handler. The req.params object is typed as Record<string, string>, which tells TypeScript nothing about which parameters actually exist:
// Express-style: everything is `string | undefined`
app.get('/users/:userId/orders/:orderId', (req, res) => {
const userId = req.params.userId // string | undefined
const orderId = req.params.orderid // Typo! No compile error, undefined at runtime
// ...
})The typo orderid instead of orderId produces no TypeScript error. At runtime, orderId is undefined, and the handler silently fetches the wrong data or throws an unhandled exception. Multiply this risk across hundreds of route handlers in a gateway, and you have a maintenance nightmare.
Hono takes a fundamentally different approach. Its routing system infers parameter types from the path string itself, using TypeScript's template literal types:
import { Hono } from 'hono'
const app = new Hono()
app.get('/users/:userId/orders/:orderId', (c) => {
const userId = c.req.param('userId') // string (guaranteed)
const orderId = c.req.param('orderId') // string (guaranteed)
const bogus = c.req.param('orderid') // Compile error!
// Argument of type '"orderid"' is not assignable to parameter of type '"userId" | "orderId"'
return c.json({ userId, orderId })
})The compiler knows exactly which parameters are available because Hono extracts them from the route path at the type level. This is not a runtime check or a code generation step -- it is pure TypeScript type inference.
Typed Route Parameters and Path Inference
Hono's type inference works through a clever use of template literal types and conditional types. When you define a route like /users/:userId, Hono's type system parses the string at compile time and extracts userId as a known parameter name.
This extends to complex path patterns:
const app = new Hono()
// Multiple parameters
app.get('/api/v1/:version/services/:serviceId/endpoints/:endpointId', (c) => {
const version = c.req.param('version') // string
const serviceId = c.req.param('serviceId') // string
const endpointId = c.req.param('endpointId') // string
return c.json({ version, serviceId, endpointId })
})
// Wildcard paths
app.get('/proxy/*', (c) => {
const path = c.req.path // The full matched path
return c.text(`Proxying to: ${path}`)
})
// Optional parameters via separate routes
app.get('/search', (c) => {
const query = c.req.query('q') // string | undefined
const page = c.req.query('page') // string | undefined
return c.json({ query, page })
})For query parameters, Hono correctly types them as string | undefined since query parameters are always optional. This forces you to handle the absence case, preventing another class of runtime errors.
Request and Response Type Contracts
Path parameters are just the beginning. A fully type-safe API needs type contracts for request bodies, query parameters, headers, and response shapes. Hono achieves this through its validator middleware, which integrates with Zod, Valibot, and other schema libraries:
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['admin', 'member', 'viewer']),
})
const UserQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
})
const app = new Hono()
app.post(
'/api/v1/users',
zValidator('json', CreateUserSchema),
(c) => {
// body is fully typed: { email: string; name: string; role: 'admin' | 'member' | 'viewer' }
const body = c.req.valid('json')
return c.json({
id: crypto.randomUUID(),
...body,
createdAt: new Date().toISOString(),
}, 201)
}
)
app.get(
'/api/v1/users',
zValidator('query', UserQuerySchema),
(c) => {
// query is typed: { page: number; limit: number; search?: string }
const { page, limit, search } = c.req.valid('query')
return c.json({
users: [],
pagination: { page, limit, total: 0 },
})
}
)The c.req.valid('json') call returns a fully typed object matching the Zod schema. If you try to access a property that does not exist in the schema, TypeScript catches it immediately. If the runtime request body does not match the schema, the validator middleware returns a 400 response before your handler executes.
In Dispatch, we define schema contracts for every service endpoint. These schemas serve triple duty: they validate incoming requests, they provide TypeScript types for handler logic, and they generate OpenAPI documentation automatically.
Route Groups with Shared Types
When building a gateway, you often have groups of routes that share common type requirements -- the same authentication context, the same environment bindings, the same header requirements. Hono's generic type parameters let you define these once and share them across route groups:
type AuthenticatedEnv = {
Variables: {
user: {
id: string
email: string
scopes: string[]
}
requestId: string
}
Bindings: {
DATABASE_URL: string
AUTH_SECRET: string
}
}
// Create a typed router for authenticated routes
const authenticatedRoutes = new Hono<AuthenticatedEnv>()
// Every handler in this group has access to typed user context
authenticatedRoutes.get('/me', (c) => {
const user = c.get('user') // Typed: { id: string; email: string; scopes: string[] }
return c.json({ user })
})
authenticatedRoutes.get('/settings', (c) => {
const user = c.get('user')
const dbUrl = c.env.DATABASE_URL // Typed: string
return c.json({ userId: user.id, settings: {} })
})
// Mount under the main app with auth middleware
const app = new Hono<AuthenticatedEnv>()
app.use('/api/v1/*', authMiddleware())
app.route('/api/v1', authenticatedRoutes)This pattern ensures that if an authentication middleware is supposed to set a user variable, every handler that accesses that variable is type-checked against the same shape. If you rename a field in the user object, the compiler flags every handler that references the old name.
RPC-Style Type Safety with hc Client
Hono offers an RPC-style client (hc) that propagates your API's type information to the client side. This is particularly powerful for service-to-service communication within a microservices architecture, where the gateway forwards requests to typed upstream services:
import { Hono } from 'hono'
import { hc } from 'hono/client'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
// Define the upstream service with typed routes
const paymentService = new Hono()
.post(
'/charge',
zValidator(
'json',
z.object({
amount: z.number().positive(),
currency: z.enum(['USD', 'EUR', 'EGP']),
customerId: z.string().uuid(),
})
),
async (c) => {
const body = c.req.valid('json')
return c.json({
transactionId: crypto.randomUUID(),
amount: body.amount,
currency: body.currency,
status: 'completed' as const,
})
}
)
.get('/transactions/:id', async (c) => {
const id = c.req.param('id')
return c.json({
id,
amount: 100,
currency: 'USD',
status: 'completed',
})
})
// Export the type for client usage
type PaymentServiceType = typeof paymentService
// In the gateway or another service, create a typed client
const client = hc<PaymentServiceType>('https://payments.internal')
// Fully typed request and response
const chargeResult = await client.charge.$post({
json: {
amount: 50.00,
currency: 'USD',
customerId: '550e8400-e29b-41d4-a716-446655440000',
},
})
const data = await chargeResult.json()
// data is typed: { transactionId: string; amount: number; currency: string; status: 'completed' }In Dispatch, we use this pattern for internal health checks and administrative API calls. The gateway's admin interface is itself a Hono app with typed routes, and our deployment tooling uses the hc client to interact with it. Any breaking change to the admin API is caught at compile time in the deployment scripts.
Advanced Pattern: Type-Safe Route Registration
For dynamic route registration -- a core requirement for any configurable gateway -- we use a builder pattern that preserves type information through the registration chain:
import { Hono } from 'hono'
import type { MiddlewareHandler } from 'hono'
class TypedRouteBuilder<E extends Record<string, unknown>> {
private app: Hono<E>
private basePath: string
private middlewareStack: MiddlewareHandler[] = []
constructor(app: Hono<E>, basePath: string) {
this.app = app
this.basePath = basePath
}
withMiddleware(...mw: MiddlewareHandler[]): this {
this.middlewareStack.push(...mw)
return this
}
get<P extends string>(path: P, handler: (c: any) => Response | Promise<Response>) {
this.app.get(`${this.basePath}${path}`, ...this.middlewareStack, handler)
return this
}
post<P extends string>(path: P, handler: (c: any) => Response | Promise<Response>) {
this.app.post(`${this.basePath}${path}`, ...this.middlewareStack, handler)
return this
}
build() {
return this.app
}
}
// Usage
const app = new Hono()
new TypedRouteBuilder(app, '/api/v1/users')
.withMiddleware(authMiddleware(), rateLimiter({ maxRequests: 100 }))
.get('/', listUsersHandler)
.get('/:id', getUserHandler)
.post('/', createUserHandler)
.build()Conclusion
Type-safe routing transforms API development from a guess-and-check process into a compiler-verified discipline. Hono's approach -- inferring types from path strings, integrating with schema validators, and propagating types to clients -- creates a safety net that catches errors before they reach production. For Dispatch, this means that routing changes, parameter renames, and schema updates are validated across the entire codebase at compile time. The upfront investment in proper type definitions pays for itself many times over in reduced debugging, faster code reviews, and confident refactoring. If you are building APIs in TypeScript, there is no reason to leave route safety to runtime chance.
Related Articles
API Monitoring and Alerting Best Practices
A comprehensive guide to monitoring API gateways in production, covering the four golden signals, structured logging, distributed tracing, and actionable alerting strategies.
Edge Computing for Fintech: Latency and Compliance Benefits
How edge computing addresses the unique challenges of fintech platforms, including latency-sensitive transactions, data residency requirements, and regulatory compliance across jurisdictions.
API Performance Optimization: From 200ms to 20ms
A practical guide to optimizing API gateway performance, covering the specific techniques that took Dispatch's p95 latency from 200ms to under 20ms.