TypeScript Conditional Types: Advanced Type Manipulation for Safer APIs
Introduction
Conditional types are one of the most powerful, yet under‑used, features of TypeScript. They let you write types that react to other types, enabling sophisticated compile‑time transformations that were previously only possible with runtime code. When designing public APIs—whether REST endpoints, GraphQL resolvers, or internal library functions—conditional types can enforce invariants, generate precise request/response shapes, and keep your implementation in sync with its contract.
In this article we’ll:
- Review the syntax and core concepts of conditional types.
- Show how to model discriminated unions, mapped response types, and payload validation without extra libraries.
- Demonstrate a real‑world “User Service” API that evolves safely as requirements change.
By the end you’ll have a toolbox of patterns you can drop into any TypeScript codebase to make your APIs safer and more maintainable.
1. The Basics – extends and infer
A conditional type follows the shape:
T extends U ? X : Y
If T can be assigned to U, the type resolves to X; otherwise it resolves to Y.
The power comes from using type parameters (T, U) that themselves may be generic, and from the optional infer keyword that extracts a piece of a type.
Example: Extracting the Resolved Value of a Promise
type Awaited<T> = T extends Promise<infer R> ? R : T;
// Usage
type Foo = Awaited<Promise<string>>; // string
type Bar = Awaited<number>; // number
infer R captures the inner type of a Promise. This pattern is the foundation for many of the utilities we’ll build later.
2. Conditional Types Meet Discriminated Unions
Many APIs accept a payload that varies based on a type discriminator. Instead of writing a massive if/else chain at runtime, we can let TypeScript infer the correct shape.
type Action =
| { kind: "create"; data: { name: string; email: string } }
| { kind: "delete"; data: { id: number } }
| { kind: "update"; data: { id: number; patch: Partial<{ name: string; email: string }> } };
type PayloadOf<K extends Action["kind"]> =
Extract<Action, { kind: K }>["data"];
// Test
type CreatePayload = PayloadOf<"create">; // { name: string; email: string }
type DeletePayload = PayloadOf<"delete">; // { id: number }
Extract is itself a conditional type that filters a union. By combining it with a generic K, we obtain a type‑level lookup table that stays in sync with the source union.
3. Mapping Request → Response Types
A common pattern in service layers is “given a request shape, return a response shape”. Conditional types let us describe this mapping once and reuse it everywhere.
// 1️⃣ Define the request union
type UserRequest =
| { op: "get"; params: { id: number } }
| { op: "search"; params: { query: string; limit?: number } }
| { op: "create"; params: { name: string; email: string } };
// 2️⃣ Define the corresponding response shapes
type UserResponseMap = {
get: { user: { id: number; name: string; email: string } };
search: { results: Array<{ id: number; name: string }> };
create: { success: true; id: number };
};
// 3️⃣ Conditional type that ties them together
type ResponseOf<R extends UserRequest> =
R extends { op: infer O }
? O extends keyof UserResponseMap
? UserResponseMap[O]
: never
: never;
// Usage examples
type GetResp = ResponseOf<{ op: "get"; params: { id: 5 } }>;
type SearchResp = ResponseOf<{ op: "search"; params: { query: "bob" } }>;
type CreateResp = ResponseOf<{ op: "create"; params: { name: "Bob"; email: "b@c.com" } }>;
If a new operation is added to UserRequest, the compiler will immediately flag any missing entry in UserResponseMap. This bidirectional contract eliminates a whole class of mismatched‑API bugs.
4. Building a Type‑Safe Service Layer
Let’s put the pieces together in a miniature “User Service”. The service receives a request object, validates it (type‑wise), performs the operation, and returns a typed response.
// 4.1 Core request/response definitions (reuse from previous sections)
type UserOp = UserRequest["op"]; // "get" | "search" | "create"
type ServiceFn<R extends UserRequest> = (req: R) => Promise<ResponseOf<R>>;
// 4.2 Implementation helpers
async function getUser(params: { id: number }) {
// pretend DB call
return { user: { id: params.id, name: "Alice", email: "alice@example.com" } };
}
async function searchUsers(params: { query: string; limit?: number }) {
return { results: [{ id: 1, name: "Bob" }, { id: 2, name: "Carol" }] };
}
async function createUser(params: { name: string; email: string }) {
return { success: true, id: Math.floor(Math.random() * 1000) };
}
// 4.3 The generic dispatcher
const handleUserRequest: ServiceFn<UserRequest> = async (req) => {
switch (req.op) {
case "get":
return getUser(req.params) as any; // cast is safe because of conditional mapping
case "search":
return searchUsers(req.params) as any;
case "create":
return createUser(req.params) as any;
}
};
// 4.4 Consumer side – fully typed
async function demo() {
const getResult = await handleUserRequest({ op: "get", params: { id: 42 } });
// getResult is inferred as { user: { id: number; name: string; email: string } }
const searchResult = await handleUserRequest({
op: "search",
params: { query: "dev", limit: 10 },
});
// searchResult is inferred as { results: Array<{ id: number; name: string }> }
const createResult = await handleUserRequest({
op: "create",
params: { name: "Dave", email: "d@e.com" },
});
// createResult is inferred as { success: true; id: number }
}
Why this is safer than a naïve any approach
- The compiler guarantees that
req.paramsmatches the operation’s expected shape. - The return type automatically reflects the operation, so downstream code can’t accidentally treat a
searchresult as agetresult. - Adding a new operation forces you to update both the request union and the response map, otherwise you’ll see a compile‑time error.
5. Conditional Types for Partial and Required Transformations
APIs often expose “create” endpoints where some fields are optional (e.g., id is generated by the server) and “update” endpoints where everything is optional. Conditional types can generate these variations automatically.
type User = {
id: number;
name: string;
email: string;
role?: "admin" | "user";
};
// Create payload: omit `id`, keep others required
type CreatePayload<T> = Omit<T, "id">;
// Update payload: make every field optional, but keep `id` required for identification
type UpdatePayload<T> = Partial<Omit<T, "id">> & { id: T["id"] };
type NewUser = CreatePayload<User>; // { name: string; email: string; role?: "admin" | "user" }
type PatchUser = UpdatePayload<User>; // { id: number; name?: string; email?: string; role?: "admin" | "user" }
If the User type evolves (e.g., a new phone field), the derived payload types automatically stay up‑to‑date.
6. Guarding Against Excess Property Checks
When a function accepts a generic type constrained by a union, TypeScript’s excess property checking can be too strict. A conditional type can relax the check only for the intended cases.
type StrictOrLoose<T> = T extends object ? T : Partial<T>;
function send<T>(payload: StrictOrLoose<T>) {
// implementation…
}
// Example
send<User>({ id: 1, name: "Eve", email: "e@f.com", extra: true }); // ❌ excess property error
send<Partial<User>>({ id: 1, extra: true }); // ✅ allowed because we passed a Partial<User>
The StrictOrLoose type decides at compile time whether to enforce exactness (T extends object) or allow a looser shape (Partial<T>). This pattern is handy for admin‑only endpoints that accept a superset of fields.
7. Recursive Conditional Types – Deep Partial
A common need is a “deep partial” version of a type, where nested objects become optional as well. Recursive conditional types make this concise.
type DeepPartial<T> = T extends Function
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
// Usage
type Config = {
db: { host: string; port: number };
cache: { ttl: number; enabled: boolean };
};
type PartialConfig = DeepPartial<Config>;
/*
{
db?: { host?: string; port?: number };
cache?: { ttl?: number; enabled?: boolean };
}
*/
DeepPartial is now a reusable utility you can import across services, ensuring that any “patch” endpoint receives a correctly typed payload without manually writing nested Partial<>s.
8. Real‑World Tip: Combine Conditional Types with Zod or io‑ts for Runtime Validation
Conditional types give you static safety, but they don’t replace runtime validation when data comes from the outside world (e.g., HTTP bodies). A pragmatic approach is to generate a type from a schema and then reuse that type in conditional utilities.
import { z } from "zod";
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "user"]).optional(),
});
type UserFromSchema = z.infer<typeof userSchema>;
type CreateUserPayload = CreatePayload<UserFromSchema>;
type UpdateUserPayload = UpdatePayload<UserFromSchema>;
Now the source of truth lives in the schema, while conditional types keep the API surface type‑safe. If the schema changes, all derived payload types adjust automatically.
9. Performance Considerations
Conditional types are evaluated only at compile time; they have zero runtime cost. However, extremely deep recursive types (e.g., DeepPartial on massive nested objects) can increase the TypeScript compiler’s workload and sometimes hit the type instantiation depth limit. If you encounter Type instantiation is excessively deep and possibly infinite, consider:
- Adding a
// @ts-ignorefor the specific line (if you’re sure it’s safe). - Refactoring the type into smaller pieces.
- Using
as constassertions to keep literal types narrow.
In most API scenarios the depth is modest, and the safety gains far outweigh the compile‑time overhead.
10. Checklist – When to Reach for Conditional Types
| Situation | Conditional‑type pattern | Benefit |
|---|---|---|
| Discriminated payloads | Extract<Union, { kind: K }>["data"] |
Guarantees correct payload per discriminator |
| Request ↔ Response mapping | R extends { op: infer O } ? Map[O] : never |
Compile‑time contract between operation and result |
| Deriving create/update DTOs | Omit<T, "id"> / Partial<Omit<T, "id">> & { id: T["id"] } |
Single source of truth for entity shape |
| Deep optional patches | Recursive DeepPartial<T> |
One‑liner for complex patch objects |
| Relaxed excess‑property checks | T extends object ? T : Partial<T> |
Allows admin‑only “super‑payloads” without breaking normal callers |
| Schema‑driven APIs | z.infer<typeof schema> + conditional utilities |
Keeps runtime validation and static typing in sync |
If you find yourself writing repetitive type transformations, a conditional type is likely the right abstraction.
11. Conclusion
Conditional types turn TypeScript from a static checker into a type‑level programming language. By leveraging extends, infer, and built‑in utilities like Extract, you can:
- Encode business rules directly in the type system.
- Keep request and response contracts tightly coupled.
- Reduce boilerplate when creating DTO variations.
- Provide developers with instant, compile‑time feedback that prevents a whole class of runtime errors.
Start by refactoring a small service layer with the patterns above. As the codebase grows, you’ll notice fewer mismatched payloads, clearer intent in function signatures, and a smoother onboarding experience for new team members—because the types now tell the story of the API.
Happy typing!
Member discussion