TypeScript Exclude and Extract in Depth: Filtering Union Types for Real API Contracts
Master Exclude and Extract utility types to build type-safe API contracts, event handlers, and conditional type helpers that catch bugs at compile time.
TypeScript Exclude and Extract in Depth: Filtering Union Types for Real API Contracts
Most union type problems stem from treating them as static lists instead of transformable sets. Teams ship API route handlers that accept internal-only paths in public contexts, event systems that route admin actions to customer callbacks, and database queries that accidentally expose soft-deleted records. The compiler stays silent because these are all valid union members—just in the wrong context.
The failure mode here is subtle but expensive. A public API endpoint that accepts "/admin/users" | "/public/users" will happily receive admin routes at runtime. The type system sees no violation because both paths belong to the union. Tests pass. The security audit finds the hole six months later.
flowchart LR
A("Route union defined") --> B("Handler accepts any member")
B --> C{"Runtime receives admin path"}
C --> D("Type check passes")
D --> E("Security violation ships")
style E stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
TypeScript's Exclude and Extract utilities solve this by transforming unions at compile time. Exclude<T, U> removes unwanted types before they reach production code. Extract<T, U> isolates only the types that match a pattern. Applied correctly, these primitives turn union types into domain-constrained contracts that fail fast during development.
flowchart LR
A("Route union defined") --> B("Exclude filters admin paths")
B --> C("PublicRoutes type enforced")
C --> D{"Handler receives admin path"}
D --> E("Compiler rejects at build")
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This distinction is critical. Teams that use Exclude and Extract defensively catch these violations in pull requests, not production incidents.
Key Takeaways
Exclude<T, U>removes all types fromTthat are assignable toU, creating a filtered union that prevents unwanted values at compile time.Extract<T, U>keeps only the types fromTthat matchU, isolating valid subsets for domain-specific handlers.- Both utilities operate on unions distributively, applying the condition to each member independently rather than treating the union as a whole.
- Real-world applications include filtering API routes, event types, database states, and permission scopes before they reach runtime logic.
- Combining
ExcludeandExtractwith conditional types enables self-documenting type transformations that encode business rules directly in the type system.
Understanding Exclude<T, U>: Removing Types From Unions
Exclude<T, U> removes every member of union T that is assignable to U. The implementation relies on TypeScript's distributive conditional types: type Exclude<T, U> = T extends U ? never : T. When T is a union, the compiler distributes this check across each member, filtering out matches.
The practical consequence matters more than the mechanics. When an API defines routes as type AllRoutes = "/admin/delete" | "/admin/create" | "/public/read" | "/public/list", developers need a way to derive PublicRoutes without manually duplicating subsets. Exclude automates this:
type AllRoutes =
| "/admin/delete"
| "/admin/create"
| "/public/read"
| "/public/list";
type PublicRoutes = Exclude<AllRoutes, `/admin/${string}`>;
// Result: "/public/read" | "/public/list"
function handlePublicRequest(route: PublicRoutes) {
// Compiler prevents "/admin/delete" from reaching this handler
console.log(`Processing public route: ${route}`);
}The template literal type /admin/${string} matches any string starting with "/admin/". Exclude removes those members, leaving only public paths. This pattern scales to hundreds of routes without manual maintenance.
flowchart TD
A("AllRoutes union (4 members)")
A --> B{"/admin/delete"}
A --> C{"/admin/create"}
A --> D{"/public/read"}
A --> E{"/public/list"}
B --> F["Exclude check: extends /admin/${string}?"]
C --> F
D --> F
E --> F
F --> G("Matched: never")
F --> H("Unmatched: kept")
G --> I["Filtered out"]
H --> J("PublicRoutes: /public/read | /public/list")
style I stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style J stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The failure mode without Exclude is copy-paste drift. Engineers add new admin routes to AllRoutes but forget to update the manually-typed PublicRoutes. The gap widens silently. Exclude makes PublicRoutes a computed type that stays synchronized automatically.
Database state machines demonstrate another high-value application. A record lifecycle typically includes states like "draft" | "published" | "archived" | "deleted". Business logic often needs to exclude terminal states:
type RecordState = "draft" | "published" | "archived" | "deleted";
type ActiveStates = Exclude<RecordState, "deleted" | "archived">;
// Result: "draft" | "published"
function transitionState(
current: ActiveStates,
next: RecordState
): RecordState {
// Compiler prevents calling this with "deleted" or "archived"
return next;
}This pattern prevents logic errors where state transition handlers receive states they cannot legally process. The type system enforces the business rule: archived and deleted records do not participate in normal workflows.
Understanding Extract<T, U>: Keeping Only Matching Types
Extract<T, U> inverts Exclude's behavior: it keeps only the members of T assignable to U. The implementation mirrors the inversion: type Extract<T, U> = T extends U ? T : never. This proves valuable when developers need a specific subset rather than "everything except."
Event systems benefit immediately from Extract. A typical application dispatches dozens of event types, but individual handlers care about a narrow subset:
type AppEvent =
| { type: "user.login"; userId: string }
| { type: "user.logout"; userId: string }
| { type: "admin.delete"; targetId: string }
| { type: "admin.create"; data: unknown }
| { type: "data.sync"; timestamp: number };
type UserEvents = Extract<AppEvent, { type: `user.${string}` }>;
// Result: { type: "user.login"; userId: string } | { type: "user.logout"; userId: string }
function handleUserEvent(event: UserEvents) {
// Compiler guarantees event.type starts with "user."
console.log(`User event: ${event.type}`);
}The template literal pattern user.${string} matches any object whose type property starts with "user.". Extract filters the union to those members. The handler receives a type-safe subset without manual enumeration.
flowchart TD
A("AppEvent union (5 members)")
A --> B{"user.login"}
A --> C{"user.logout"}
A --> D{"admin.delete"}
A --> E{"admin.create"}
A --> F{"data.sync"}
B --> G["Extract check: type extends user.${string}?"]
C --> G
D --> G
E --> G
F --> G
G --> H("Matched: kept")
G --> I("Unmatched: never")
H --> J("UserEvents: user.login | user.logout")
I --> K["Filtered out"]
style J stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style K stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Permission scopes reveal another practical application. OAuth systems often define dozens of scopes, but API endpoints validate against specific prefixes:
type Scope =
| "read:user"
| "write:user"
| "read:admin"
| "write:admin"
| "read:billing"
| "write:billing";
type ReadScopes = Extract<Scope, `read:${string}`>;
// Result: "read:user" | "read:admin" | "read:billing"
function checkReadPermission(scope: ReadScopes): boolean {
// Only read scopes reach this function
return scope.startsWith("read:");
}This pattern prevents write-permission checks from accidentally entering read-only validation logic. The type boundary enforces the domain constraint before runtime.
The implication here is that Extract works best when developers need to isolate a category rather than remove outliers. If the goal is "everything user-related," use Extract. If the goal is "everything except admin routes," use Exclude.
Real-World Pattern: Filtering API Route Types
Production API contracts demonstrate the compounding value of union filtering. A typical REST API defines routes as discriminated unions, each member carrying its method, path, and payload shape:
type ApiRoute =
| { method: "GET"; path: "/users"; params: { page: number } }
| { method: "POST"; path: "/users"; body: { name: string } }
| { method: "DELETE"; path: "/users/:id"; params: { id: string } }
| { method: "GET"; path: "/admin/logs"; params: { since: Date } }
| { method: "POST"; path: "/admin/config"; body: { key: string; value: string } };
type PublicRoutes = Exclude<ApiRoute, { path: `/admin/${string}` }>;
type AdminRoutes = Extract<ApiRoute, { path: `/admin/${string}` }>;
type GetRoutes = Extract<ApiRoute, { method: "GET" }>;
type MutationRoutes = Exclude<ApiRoute, { method: "GET" }>;
function handlePublicRequest(route: PublicRoutes) {
// route.path is guaranteed to be "/users" only
// admin paths are compiler-rejected
}
function handleAdminRequest(route: AdminRoutes) {
// route.path is guaranteed to be "/admin/logs" | "/admin/config"
}The filtering cascades through the codebase. Middleware that validates public endpoints receives PublicRoutes, preventing admin routes from entering that pipeline. Rate limiters that apply different quotas for mutations receive MutationRoutes, excluding read-only operations.
This matters because manual union subsets drift. When a new admin route ships, developers must remember to update every handler that excludes admin paths. Exclude and Extract make those subsets computed properties of the source union—add a route once, and all filters update automatically.
The cost of not using this pattern shows up in incident reports. A team adds { method: "POST"; path: "/admin/purge"; body: { confirm: boolean } } to ApiRoute. They forget to update the public middleware filter. The purge endpoint becomes publicly accessible. Exclude prevents this by deriving PublicRoutes from the current union state, not a stale manual copy.
Advanced Pattern: Conditional Type Helpers with Exclude and Extract
Combining Exclude and Extract with conditional types creates reusable type transformation utilities. These helpers encode business logic once and apply it consistently across the codebase.
A common pattern is extracting routes by HTTP method:
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type RoutesByMethod<T, M extends HttpMethod> = Extract<T, { method: M }>;
type GetRoutes = RoutesByMethod<ApiRoute, "GET">;
type PostRoutes = RoutesByMethod<ApiRoute, "POST">;
// Extract payload types from POST routes
type PostPayloads<T> = Extract<T, { method: "POST" }> extends { body: infer B }
? B
: never;
type AllPostPayloads = PostPayloads<ApiRoute>;
// Result: { name: string } | { key: string; value: string }The RoutesByMethod helper abstracts the Extract pattern, making intent explicit. Teams read RoutesByMethod<ApiRoute, "POST"> and understand the result without parsing the utility syntax.
Payload extraction demonstrates a deeper pattern: chaining Extract with conditional type inference. PostPayloads<T> first filters to POST routes, then extracts the body property from each. The result is a union of all POST payload shapes, useful for validation middleware:
function validatePostPayload(data: unknown): data is AllPostPayloads {
// Type guard that checks against all valid POST shapes
return (
typeof data === "object" &&
data !== null &&
("name" in data || ("key" in data && "value" in data))
);
}Database query builders benefit from similar helpers:
type DbOperation =
| { type: "select"; table: string; columns: string[] }
| { type: "insert"; table: string; data: Record<string, unknown> }
| { type: "update"; table: string; data: Record<string, unknown>; where: string }
| { type: "delete"; table: string; where: string };
type ReadOperations = Extract<DbOperation, { type: "select" }>;
type WriteOperations = Exclude<DbOperation, { type: "select" }>;
type OperationsByTable<T, TableName extends string> = Extract<
T,
{ table: TableName }
>;
type UserOperations = OperationsByTable<DbOperation, "users">;
// All operations targeting "users" tableThe OperationsByTable helper isolates operations for a specific table, enabling per-table security policies or caching logic. This pattern scales to dozens of tables without duplicating filter logic.
The failure mode here is inline Extract and Exclude calls scattered throughout the codebase. Each developer writes their own filter, some use Extract, others use Exclude, and maintenance becomes archaeological work. Named helpers centralize the logic and document the intent.
Exclude vs Extract vs Omit vs Pick: Choosing the Right Tool
Developers often confuse Exclude, Extract, Omit, and Pick because they all filter types. The distinction lies in what they operate on: Exclude and Extract filter union members, while Omit and Pick filter object properties.
Exclude<T, U> removes union members assignable to U. Extract<T, U> keeps only union members assignable to U. Both operate distributively across the union. Omit<T, K> removes properties K from object type T. Pick<T, K> keeps only properties K from object type T. Both operate on object keys, not union members.
flowchart LR
subgraph Union["Union Filters"]
A("Exclude<T, U>")
B("Extract<T, U>")
end
subgraph Object["Object Filters"]
C("Omit<T, K>")
D("Pick<T, K>")
end
A --> E("Removes matching members")
B --> F("Keeps matching members")
C --> G("Removes specified keys")
D --> H("Keeps specified keys")
E --> I{"Input: union type"}
F --> I
G --> J{"Input: object type"}
H --> J
style I stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style J stroke:#7c9cf0,fill:#142544,color:#eaf2ff
The choosing criteria are straightforward:
- Union of values →
ExcludeorExtract - Object properties →
OmitorPick - Removing types →
ExcludeorOmit - Keeping types →
ExtractorPick
A common mistake is attempting to use Omit on a union:
type Status = "pending" | "approved" | "rejected";
type ActiveStatus = Omit<Status, "rejected">; // ERROR: Omit expects object type
// Correct approach
type ActiveStatus = Exclude<Status, "rejected">; // "pending" | "approved"Omit requires an object type because it filters property keys. Status is a union of string literals, not an object with properties. Exclude handles unions correctly.
Conversely, using Exclude on object properties fails:
type User = { id: string; name: string; email: string };
type PublicUser = Exclude<User, "email">; // ERROR: type mismatch
// Correct approach
type PublicUser = Omit<User, "email">; // { id: string; name: string }Exclude<User, "email"> attempts to remove the string literal "email" from the object type User. The operation is meaningless because User is not a union containing "email". Omit operates on the object's property keys.
The implication here is that Exclude/Extract and Omit/Pick solve different problems. Teams that understand this distinction write clearer types and avoid runtime surprises from misapplied utilities.
Practical Applications: Type-Safe API Contracts and Event Handlers
Production codebases reveal recurring patterns where Exclude and Extract prevent entire classes of bugs. API middleware stacks demonstrate the value immediately.
Consider a Next.js application with API routes split between public and authenticated endpoints:
type ApiEndpoint =
| { path: "/api/public/status"; auth: false; response: { uptime: number } }
| { path: "/api/public/health"; auth: false; response: { status: string } }
| { path: "/api/user/profile"; auth: true; response: { name: string; email: string } }
| { path: "/api/user/settings"; auth: true; response: { theme: string } }
| { path: "/api/admin/users"; auth: true; role: "admin"; response: { users: unknown[] } };
type PublicEndpoints = Extract<ApiEndpoint, { auth: false }>;
type AuthenticatedEndpoints = Extract<ApiEndpoint, { auth: true }>;
type AdminEndpoints = Extract<ApiEndpoint, { role: "admin" }>;
function publicMiddleware(endpoint: PublicEndpoints["path"]) {
// Compiler guarantees endpoint is "/api/public/status" | "/api/public/health"
console.log(`Public access: ${endpoint}`);
}
function authMiddleware(endpoint: AuthenticatedEndpoints["path"]) {
// Compiler guarantees endpoint requires authentication
console.log(`Authenticated access: ${endpoint}`);
}The middleware signatures enforce access control at compile time. A developer cannot accidentally pass "/api/user/profile" to publicMiddleware because it is not assignable to PublicEndpoints["path"]. The type system catches the violation during development.
flowchart LR
A("API request received") --> B{"Extract by auth level"}
B --> C("PublicEndpoints")
B --> D("AuthenticatedEndpoints")
C --> E("publicMiddleware validates")
D --> F("authMiddleware validates")
E --> G("Response returned")
F --> H{"Extract by role"}
H --> I("AdminEndpoints")
H --> J("UserEndpoints")
I --> K("adminMiddleware validates")
J --> G
K --> G
style K stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
Event-driven architectures benefit from similar patterns. A real-time notification system dispatches events to subscribers based on event categories:
type NotificationEvent =
| { category: "user"; type: "login"; userId: string }
| { category: "user"; type: "logout"; userId: string }
| { category: "system"; type: "error"; message: string }
| { category: "system"; type: "warning"; message: string }
| { category: "admin"; type: "audit"; action: string; actor: string };
type UserNotifications = Extract<NotificationEvent, { category: "user" }>;
type SystemNotifications = Extract<NotificationEvent, { category: "system" }>;
type AdminNotifications = Extract<NotificationEvent, { category: "admin" }>;
class NotificationSubscriber {
onUserEvent(event: UserNotifications) {
// event.category is guaranteed to be "user"
// event.type is "login" | "logout"
console.log(`User event: ${event.type}`);
}
onSystemEvent(event: SystemNotifications) {
// event.category is guaranteed to be "system"
// event.type is "error" | "warning"
console.log(`System event: ${event.type} - ${event.message}`);
}
}The subscriber methods receive category-specific unions. The compiler prevents onUserEvent from receiving system or admin events. Teams can add new event types to NotificationEvent without updating every subscriber—the Extract filters adapt automatically.
Database query builders demonstrate another high-value application. An ORM defines query operations as a discriminated union, and different execution contexts need different subsets:
type QueryOperation =
| { type: "read"; table: string; columns: string[] }
| { type: "write"; table: string; data: Record<string, unknown> }
| { type: "transaction"; operations: QueryOperation[] };
type ReadOnlyOperations = Exclude<QueryOperation, { type: "write" | "transaction" }>;
type WriteOperations = Extract<QueryOperation, { type: "write" | "transaction" }>;
function executeReadOnly(op: ReadOnlyOperations) {
// Compiler guarantees op.type === "read"
// No writes or transactions can reach this function
}
function executeWriteWithLock(op: WriteOperations) {
// Compiler guarantees op.type === "write" | "transaction"
// Read operations are excluded
}This pattern prevents read-only database replicas from receiving write operations. The type boundary enforces the runtime constraint: replicas execute ReadOnlyOperations, primaries execute WriteOperations. Misconfiguration becomes a compile-time error instead of a production outage.
Frequently Asked Questions
When should developers use Exclude instead of Extract?
Use Exclude when the goal is removing specific unwanted types from a union, and the majority of members should remain. Use Extract when isolating a specific subset is clearer than listing everything to remove. If filtering out admin routes from 50 public routes, Exclude<AllRoutes, AdminRoutes> is more maintainable than Extract<AllRoutes, PublicRoute1 | PublicRoute2 | ...>.
Can Exclude and Extract filter object properties like Omit and Pick?
No. Exclude and Extract operate on union members, not object properties. To filter object keys, use Omit to remove properties or Pick to keep specific ones. Attempting Exclude<User, "email"> on an object type produces a type error because User is not a union containing the string literal "email".
How do template literal types enhance Exclude and Extract patterns?
Template literal types enable pattern-based filtering without enumerating every member. Exclude<Routes, \/admin/${string}`>` removes all routes starting with "/admin/" regardless of how many exist. This scales to hundreds of routes and stays synchronized as new routes are added, eliminating manual maintenance of filter lists.
What happens when Exclude removes all union members?
If Exclude<T, U> removes every member of T, the result is never. This indicates no types satisfy the filter criteria. In practice, assigning never to a variable or parameter creates a compile error because no value can inhabit the type, surfacing the logic error immediately.
Do Exclude and Extract work with complex discriminated unions?
Yes. Both utilities distribute over union members and respect structural typing. Extract<ApiRoute, { method: "POST"; path: \/admin/${string}` }>` filters to POST routes with admin paths, checking both properties. The discriminated union pattern makes this especially powerful for routing and validation logic.
Building Safer Union Type Transformations
The patterns covered here solve a specific problem: keeping union types synchronized with domain constraints as codebases evolve. Teams that apply Exclude and Extract defensively catch access control violations, routing errors, and state machine bugs during pull request review instead of production incidents.
The compound value comes from computed types that never drift. When PublicRoutes derives from AllRoutes via Exclude, adding a new route updates both automatically. Manual subsets decay the moment someone forgets to synchronize them. The compiler enforces consistency.
That covers the essential patterns for union type filtering in TypeScript. Apply these in production API contracts, event systems, and database query builders, and the difference will be immediate: fewer runtime type guards, clearer domain boundaries, and security policies that cannot be accidentally bypassed.