Skip to Content
Course content

133: Whiteboard Practice: Designing Types for a Real API

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

When I'm in a technical interview or whiteboarding a new feature with my team, the biggest mistake I see is people jumping straight to the "perfect" type. They try to architect the entire system in one go. In reality, designing types for a real API is more like sculpting; you start with a block of marble and chip away at the parts that don't make sense.

Let's imagine we're building a Project Management API—something like Linear or Jira. We need to model a Task. My first instinct is usually to just list every possible field I can think of.

Starting too broad

interface Task {
  id: string;
  title: string;
  status: 'todo' | 'in-progress' | 'done';
  assigneeId?: string;
  completedAt?: Date;
}

At first glance, this looks fine. But as I start thinking about the business logic, I realize this type is actually lying to me. If the status is 'todo', the completedAt field must be undefined. If the status is 'done', the completedAt field must be present. With the current interface, TypeScript is happy to let me create a task that is 'todo' but has a completion date, which is a logical impossibility in our app.

Wait, this is actually an illegal state

I want to make the types reflect the actual state of the data. This is where I move from a single interface to a Discriminated Union. I'll split the task into different states based on the status.

type Task = TodoTask | InProgressTask | DoneTask;

interface BaseTask {
  id: string;
  title: string;
}

interface TodoTask extends BaseTask {
  status: 'todo';
  assigneeId?: string;
}

interface InProgressTask extends BaseTask {
  status: 'in-progress';
  assigneeId: string; // Now required, because someone must be working on it
}

interface DoneTask extends BaseTask {
  status: 'done';
  assigneeId: string;
  completedAt: Date; // Now mandatory for done tasks
}

Now, if I try to access task.completedAt on a TodoTask, TypeScript will scream at me. I have to check the status first. This is a huge win because it moves the "validation" from a runtime if statement into the type system itself.

Wrapping the response

Now, we aren't just dealing with a raw Task object. This is an API. Every response from our server usually follows a specific envelope pattern—maybe it has a status code, a timestamp, or pagination metadata. I don't want to redefine that envelope for every single endpoint.

I'll try a generic wrapper. This is a pattern I use in almost every professional project I touch.

interface ApiResponse<T> {
  data: T;
  meta: {
    requestId: string;
    timestamp: string;
  };
  error?: {
    code: string;
    message: string;
  };
}

// Now I can use it for a single task
type TaskResponse = ApiResponse<Task>;

// Or for a list of tasks
type TaskListResponse = ApiResponse<Task[]>;

By using a generic T, I've created a reusable shell. If we add a User or Project entity later, I don't have to write UserResponse or ProjectResponse interfaces from scratch.

The 'Partial' trap

Finally, I need to think about updating a task. Usually, an UPDATE /tasks/:id endpoint allows you to send only the fields you want to change. My first thought is to use the Partial<T> utility type.

type UpdateTaskRequest = Partial<Task>;

But wait. If I use Partial<Task>, I'm telling the API that it's okay to send an id in the request body. In our API design, the id is part of the URL, not the body. Allowing it in the body creates ambiguity—what happens if the ID in the URL is 123, but the ID in the body is 456?

I need to strip the id out. I'll use Omit combined with Partial.

type UpdateTaskRequest = Partial<Omit<Task, 'id'>>;

Now we have a type that allows any field to be optional, but explicitly forbids the id from being included. It's a small detail, but this is the difference between a "textbook" type and a "production" type. We've gone from a loose interface to a strict, state-aware system that prevents bugs before the code even runs.




📋 Practical Task

Designing the "Subscription Plan" API Types

You are designing the types for a SaaS Subscription API. The API has three tiers: 'free', 'pro', and 'enterprise'.

Your goal is to create a type system that enforces the following rules:

  • All plans must have an id and a price.
  • The 'free' plan cannot have a supportTier.
  • The 'pro' plan must have a supportTier of either 'email' or 'chat'.
  • The 'enterprise' plan must have a supportTier of 'dedicated' and must include a customContractUrl (string).
  • Create a generic ApiEnvelope<T> that wraps the data and includes a version string.
  • Create a type called UpdatePlanRequest that allows updating any field except the id.

Write your solution in TypeScript. Test your union types by attempting to create a 'free' plan with a customContractUrl to ensure the compiler throws an error.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.