There is nothing more frustrating in API Type Design than a backend endpoint that changes its response shape based on runtime logic. One moment it returns a clean JSON object, and on error, it returns a completely different structure. If you are still defining these with loose Union types or—heaven forbid—any, you are essentially disabling the compiler. In a recent high-traffic dashboard refactor, we eliminated 90% of our runtime "undefined is not a function" errors by shifting from static interfaces to dynamic, conditional type definitions.
The Problem with Static Interfaces
Standard interfaces work well for static data, but modern APIs are often polymorphic. A common pattern is an endpoint that returns a data payload on success, but an error object with a code on failure. Using a simple Union type (Success | Error) forces developers to write repetitive type guards (if ('data' in res)) everywhere in the codebase.
To achieve real Frontend Stability, we need TypeScript Advanced Types to infer the correct shape automatically based on a discriminator field (like status or http_code). This isn't just about clean code; it's about preventing the UI from rendering a success component when the backend actually threw a silent 200 OK error.
Lazy typing defeats the purpose of TypeScript. If you cast an API response to `any`, you are effectively writing JavaScript with extra steps.
The Solution: Conditional Types & Infer
The most robust way to handle this is using Conditional Types. We can create a generic type that inspects the input type and "decides" what the output structure should be. This allows us to strictly couple the status code to the payload shape.
Here is a production-ready pattern using the infer keyword to extract the exact data type depending on the API's behavior.
// 1. Define the possible shapes of your API
type SuccessResponse<T> = {
status: 'success';
data: T;
timestamp: number;
};
type ErrorResponse = {
status: 'error';
error: {
code: number;
message: string;
};
};
// 2. Create a Conditional Type that selects the shape based on the Generic
// If T is explicitly 'never', we assume it's an error-only scenario.
type ApiResponse<T = never> = [T] extends [never]
? ErrorResponse
: SuccessResponse<T> | ErrorResponse;
// 3. Usage in a real Fetch wrapper
async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(url);
return response.json();
}
// 4. Implementation Example
interface UserProfile {
id: number;
name: string;
}
async function loadUser() {
// TypeScript knows this can be Success OR Error
const result = await fetchApi<UserProfile>('/api/user/1');
if (result.status === 'error') {
// TypeScript AUTO-NARROWS this to ErrorResponse
// console.log(result.data); // Error: Property 'data' does not exist
console.error(result.error.message);
return;
}
// TypeScript AUTO-NARROWS this to SuccessResponse<UserProfile>
console.log(result.data.name);
}
[T] extends [never]. This is a specific TypeScript trick to prevent the distribution of union types, ensuring the condition is checked against the type as a whole.
Unpacking Types with `infer`
Sometimes you receive a complex nested type from a library (like GraphQL generated types) and you need to extract just the return type of a function. This is where infer shines in your TypeScript Tips toolkit.
// Utility to extract the type inside a Promise response
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
// Usage
type UserData = UnpackPromise<ReturnType<typeof loadUser>>;
// Result: void (because our function returns nothing) or the specific object depending on implementation.
Maintenance & Safety Comparison
Why go through this trouble? The ROI becomes clear when you look at maintenance costs over time.
| Feature | Standard Interface | Conditional Types (Recommended) |
|---|---|---|
| Type Safety | Medium (Requires manual casting) | High (Auto-narrowing) |
| Refactoring Speed | Slow (Find/Replace limits) | Fast (Compiler errors guide you) |
| Developer Experience | Confusing (Optional chaining `?.`) | Clean (Direct access) |
| Runtime Errors | Frequent (Unhandled states) | Rare (Exhaustive checks enforced) |
Conclusion
Designing flexible APIs isn't just a backend job; the frontend must consume them intelligently. By leveraging Conditional Types, you move the complexity from runtime debugging to compile-time verification. This approach ensures your application handles schema changes gracefully and maintains Frontend Stability even when the API behaves unexpectedly. Stop fighting the compiler—make it work for you.
Post a Comment