-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
133: Whiteboard Practice: Designing Types for a Real API
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
idand aprice. - The
'free'plan cannot have asupportTier. - The
'pro'plan must have asupportTierof either'email'or'chat'. - The
'enterprise'plan must have asupportTierof'dedicated'and must include acustomContractUrl(string). - Create a generic
ApiEnvelope<T>that wraps the data and includes aversionstring. - Create a type called
UpdatePlanRequestthat allows updating any field except theid.
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.
There are no comments for now.