Skip to Content
Course content

248: Practice Exercise: Building a Complete Typed API Layer End to End

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Look, we've all been there. You're in a rush to get a feature shipped, you call an endpoint, and you just want the data on the screen. The temptation to use a type assertion—that little as User or as Project[]—is incredibly strong because it satisfies the compiler instantly. But here is the hard truth: as is essentially you telling TypeScript to shut up and trust you. The problem is that TypeScript doesn't exist at runtime, but your API definitely does.

The "Trust Me, I'm an Engineer" Approach

In a naive implementation, you might write a fetch wrapper that looks something like this. It feels clean, it's concise, and it gives you autocomplete in your components.

async function fetchProject(id: string): Promise<Project> {
  const response = await fetch(`/api/projects/${id}`);
  return response.json() as Project;
}

On the surface, this is great. You get a Project object back, and your IDE tells you exactly which properties are available. But you've created a "lying type." You aren't actually guaranteeing that the response matches the Project interface; you're just pretending it does. If the backend team decides to rename project_name to title, or if a field you thought was mandatory suddenly comes back as null, TypeScript won't warn you. Your app will simply crash with a Cannot read property 'x' of undefined in production, and you'll spend two hours debugging a "type-safe" application.

When the Boundary Breaks

The danger here is that the API boundary is the most volatile part of your system. You don't control the database, you might not control the backend, and even if you do, the network is a chaotic place. When we rely on as, we are pushing the point of failure deep into our UI components. A component might try to map over an array that is actually undefined because the API returned a 404 with an error object instead of a list.

I've seen this happen in huge enterprise apps where a single null value in a deeply nested API response takes down the entire dashboard. The "cost" of the naive approach is low upfront (it's fast to write), but the maintenance cost is astronomical because you can't actually trust your types.

Hardening the Boundary with Runtime Validation

The better way—the professional way—is to treat the API response as unknown until it is proven otherwise. We do this by moving the validation to the very edge of the application. I typically reach for a library like Zod for this, because it allows us to define a schema once and derive the TypeScript type from that schema automatically.

import { z } from 'zod';

const ProjectSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  status: z.enum(['active', 'archived', 'draft']),
  dueDate: z.string().datetime(),
});

type Project = z.infer<typeof ProjectSchema>;

async function fetchProject(id: string): Promise<Project> {
  const response = await fetch(`/api/projects/${id}`);
  const data = await response.json();
  
  // This is where the magic happens. 
  // If the data is wrong, it throws an error HERE, not in your UI.
  return ProjectSchema.parse(data);
}

Now, we aren't guessing. If the API returns something weird, ProjectSchema.parse() will throw a descriptive error immediately. This transforms a mysterious runtime crash in a React component into a clear, catchable error at the network layer. You've essentially created a firewall for your data.

Balancing the Boilerplate Cost

I'll be honest with you: this is more work. You're writing a schema and an interface. You're adding a dependency. For a tiny hobby project, this might be overkill. But for anything that someone else relies on, the trade-off is a no-brainer. You trade a few minutes of boilerplate for the absolute certainty that if a function returns a Project, it actually is a Project.

When you build your API layer this way, your components become boring. And in software engineering, "boring" is a compliment. Boring components don't have to handle unexpected nulls or check if a property exists before using it; they just render the data they were promised.




📋 Practical Task

Exercise: Implementing a Validated Subscription Service

You are building a subscription management portal. The API is notorious for returning inconsistent data (sometimes missing fields, sometimes wrong types). Your goal is to build a type-safe service that prevents this "garbage data" from reaching the UI.

Requirements:

  • Define a Zod schema for a Subscription object. It must have:
    • id (string)
    • plan (an enum of 'basic', 'premium', or 'enterprise')
    • isActive (boolean)
    • renewalDate (a valid ISO date string)
  • Create a TypeScript type Subscription inferred from that schema.
  • Implement a function getSubscription(userId: string) that:
    • Fetches data from /api/subscriptions/${userId}.
    • Parses the JSON response using your schema.
    • Wraps the parsing logic in a try/catch block. If validation fails, it should throw a custom error: "API_CONTRACT_VIOLATION".

Test Case: Your code should be able to handle a response where plan is unexpectedly returned as "gold" (which is not in your enum) by throwing the specific API_CONTRACT_VIOLATION error rather than allowing the invalid object to be returned.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.