Resilient API Clients in TypeScript: why code generation is not the perfect solution
In the world of modern frontend development, we’ve become obsessed with type safety. We want our IDEs to tell us exactly what an API returns before we even make the request, and we want to ensure we are working with valid data structures at every step. To achieve this, the industry has largely converged on a single solution: generating TypeScript clients from OpenAPI specifications.
It sounds like a win-win. But relying entirely on static generation can lead us into a “tightly-coupled RPC trap,” creating maintenance headaches that simpler architectures avoid.
The Reality of Business APIs#
Let’s be clear: in real-world business applications, strict REST compliance isn’t always the goal—or even possible. Sometimes you just need to perform an action that doesn’t fit neatly into a resource box, and that is perfectly fine.
The problem isn’t that we have non-RESTful operations, nor is it that we use OpenAPI (which is an excellent tool for documentation). The problem arises when we treat that documentation as a static contract for our client code.
The Problem with Static Generation#
When we generate a TypeScript client, we are essentially performing early binding. We take a snapshot of the documentation at a specific point in time and bake it into our client-side codebase.
This creates a form of coupling that mimics RPC (Remote Procedure Call) patterns, regardless of how “RESTful” your API claims to be.
As Roy Fielding noted in his seminal dissertation, Architectural Styles and the Design of Network-based Software Architectures, the defining characteristic of REST is the Uniform Interface (Section 5.1.5). This interface is specifically designed to decouple implementations from the services they provide, encouraging independent evolvability.
By hardcoding every data model into static types, we violate this core principle:
- The Coupling Trap: Developers start treating the API like a local library or a direct database connection. We hardcode URL paths (e.g., /users/${id}) and assume the structure is immutable.
- Stale Contracts: If the server evolves—adding a field or changing a structure—the client’s hardcoded types go “stale” immediately. This forces a synchronized deployment of both backend and frontend, defeating the purpose of separating them in the first place.
Adaptive Generation vs. Static Snapshots#
Generation isn’t inherently “bad”—it’s a massive booster for Developer Experience (DX). We want the IDE support and the confidence of valid data. The solution is to make our generation adaptive rather than static.
Instead of generating a rigid skeleton of the entire API, adaptive generation focuses on:
- Discovery mechanisms: Generating types for link relationships (
_links,@id) so the client can navigate the API dynamically (HATEOAS). - Protocol awareness: Using libraries that understand the API’s grammar (like @api-platform/ld) rather than hardcoding every single endpoint.
- Extensibility: Using types that allow for additional, unknown fields (like using
Record<string, unknown>), preventing the client from crashing just because the server added a new feature.
Entities vs. Resource Representations#
A common pitfall with static generation is treating persistence models (Entities) as API models.
In a decoupled architecture, the client shouldn’t care about the server’s database schema. It should only care about the Representation—the current state of the resource it requested. When we generate types directly from a database-driven OpenAPI, we inadvertently leak backend implementation details into the frontend, making refactoring significantly harder.
A Better Way: Trust but Verify (Runtime Validation)#
Instead of generating a static “snapshot” of every possible entity and assuming it will never change, we should shift our focus to the Application Boundary.
Since TypeScript types are erased at runtime, we cannot rely on “simple interfaces” to validate incoming data. We need Runtime Validation. Zod is the industry standard, but there are alternatives like Valibot or ArkType.
We can use these tools to define the Resource Representation we actually need for a specific UI component. This provides the “valid data structures” we crave while maintaining resilience.
import { z } from 'zod';
// Define what we NEED and VALIDATE it at runtime
// Example using JSON-LD format
const UserRepresentation = z.looseObject({
'@id': z.string(), // The stable identifier (IRI)
'@type': z.literal('User'), // Ensure we have the right type
displayName: z.string(),
posts: z.string(), // In JSON-LD, the link is just the IRI string
});
type User = z.infer<typeof UserRepresentation>;
By using .looseObject() (or similar configurations in other libraries), we get strict type safety and runtime validation for the data we consume, but our code remains decoupled. If the server adds 20 new fields, our client doesn’t break. We only validate—and typehint—what we use at the moment of consumption.
Conclusion#
Type safety is a requirement for modern apps, but it shouldn’t come at the cost of architectural flexibility. We can have our IDE support and our valid data structures without locking ourselves into a rigid contract.
By moving away from static snapshots and toward Adaptive Generation and Runtime Validation, we build clients that are robust enough to handle the messy reality of evolving business applications.
If you like my thoughts on API design and performance, consider sponsoring my work on GitHub.