🚀 Executive Summary
TL;DR: TypeScript’s built-in `Omit` utility type fails when applied directly to discriminated unions, collapsing them to `never` because it isn’t distributive. This issue is effectively solved by creating custom distributive utility types, such as `DistributiveOmit`, which apply the `Omit` operation to each union member individually.
🎯 Key Takeaways
- The `Omit` utility type is not distributive by default, meaning it operates on the intersection of properties across union members rather than each member individually, leading to the `never` type for discriminated unions.
- Distributive conditional types (e.g., `T extends any ? Omit
: never`) are the canonical solution to force `Omit` to apply to each member of a union, creating a reusable `DistributiveOmit` utility. - An advanced alternative involves key remapping within mapped types (`{ [P in keyof T as P extends K ? never : P]: T[P] }`) to filter out unwanted properties distributively across union members.
Learn why TypeScript’s built-in `Omit` utility type fails with discriminated unions, often collapsing them to the `never` type, and discover three robust solutions. This guide provides practical code examples using manual re-unioning, distributive conditional types, and key remapping to correctly handle type manipulation in complex scenarios.
The Problem: Why `Omit` Breaks Discriminated Unions
As a DevOps engineer, you frequently model complex event streams, CI/CD pipeline states, or infrastructure configurations. Discriminated unions in TypeScript are a perfect tool for this, allowing you to create a type that can be one of several distinct shapes, differentiated by a common “discriminant” property like type or status.
The problem arises when you need to create a new type by removing a property from each member of that union. Your first instinct is to reach for the built-in `Omit` utility type, but you quickly run into a frustrating issue.
Symptoms: The `never` Type
Let’s model a set of server deployment events. Each event has a unique `type` and some specific properties, but they all share a `timestamp` and `correlationId` we might want to remove for a specific use case.
type DeployStartEvent = {
type: 'DEPLOY_START';
env: 'staging' | 'production';
commitSha: string;
timestamp: number;
correlationId: string;
};
type DeploySuccessEvent = {
type: 'DEPLOY_SUCCESS';
url: string;
timestamp: number;
correlationId: string;
};
type DeployFailureEvent = {
type: 'DEPLOY_FAILURE';
error: string;
logs: string;
timestamp: number;
correlationId: string;
};
type ServerEvent = DeployStartEvent | DeploySuccessEvent | DeployFailureEvent;
Now, let’s try to create a new event type without the `correlationId` using the standard `Omit` utility:
// This does NOT work as expected!
type EventWithoutCorrelation = Omit<ServerEvent, 'correlationId'>;
// When you hover over EventWithoutCorrelation in your IDE, it shows:
// type EventWithoutCorrelation = never
Instead of a new union of our event types, we get `never`. This means the resulting type is an empty set, which is useless and will cause type errors downstream.
Root Cause: `Omit` Isn’t Distributive
The core of the issue is that utility types like `Omit` and `Pick` are not “distributive” by default. When you apply `Omit` to a union type, it doesn’t apply the operation to each member of the union individually. Instead, it operates on the intersection of the properties available across all members.
In our `ServerEvent` example, `keyof ServerEvent` resolves to only the properties common to all three types: type | 'timestamp' | 'correlationId'. Properties like `commitSha` or `error` are not included because they don’t exist on every member of the union.
The `Omit` utility is defined roughly as `Pick
Solution 1: Manual Re-Unioning
The most straightforward and explicit solution is to manually apply `Omit` to each constituent member of the union and then join them back together with the `|` operator.
How It Works
This approach sidesteps the core problem by deconstructing the union, operating on each concrete type individually (where `Omit` works perfectly), and then reconstructing the union from the modified types.
Example Implementation
type EventWithoutCorrelation =
| Omit<DeployStartEvent, 'correlationId'>
| Omit<DeploySuccessEvent, 'correlationId'>
| Omit<DeployFailureEvent, 'correlationId'>;
// This works! The resulting type is now a correct discriminated union:
// type EventWithoutCorrelation =
// | { type: 'DEPLOY_START'; env: ...; commitSha: ...; timestamp: ...; }
// | { type: 'DEPLOY_SUCCESS'; url: ...; timestamp: ...; }
// | { type: 'DEPLOY_FAILURE'; error: ...; logs: ...; timestamp: ...; }
While effective, this method is not scalable. If you add a new event type to `ServerEvent`, you must remember to also update `EventWithoutCorrelation`. It’s a maintainability burden best reserved for simple, one-off transformations.
Solution 2: The `DistributiveOmit` Utility Type
The canonical and most reusable solution is to create a custom utility type that forces the `Omit` operation to be “distributive” over the union.
How It Works
This technique leverages a powerful feature of TypeScript called distributive conditional types. When a conditional type (T extends U ? X : Y) has a “naked” type parameter on the left side of `extends` (like `T` in our example), it distributes the condition over a union.
By wrapping `Omit` in a conditional type like T extends any ? Omit<T, K> : never, we tell TypeScript: “For each member `T` in the union, apply `Omit
Example Implementation
First, define the reusable utility type:
type DistributiveOmit<T, K extends keyof any> = T extends any
? Omit<T, K>
: never;
Now, you can use it on any union type, and it will work as expected:
// Use the new utility type
type EventWithoutCorrelation = DistributiveOmit<ServerEvent, 'correlationId'>;
// The result is the correct, maintainable union type, same as the manual solution.
// If you add a new event to the ServerEvent union, this type updates automatically.
This is the preferred solution for most use cases due to its elegance, reusability, and maintainability.
Solution 3: Key Remapping with Mapped Types
A third, more advanced approach involves creating a distributive utility type using mapped types and key remapping. This offers another way to achieve the same result and demonstrates a deeper TypeScript pattern.
How It Works
This solution also uses a distributive conditional type to iterate over the union. Inside the conditional, it uses a mapped type ` { [P in keyof T as …] }` to construct a new object type. The key remapping `as P extends K ? never : P` is the crucial part: for each property `P` in the original type, it checks if `P` is one of the keys to be omitted (`K`). If it is, the key is remapped to `never`, effectively filtering it out. Otherwise, the key `P` is kept.
Example Implementation
Here is the utility type definition:
type OmitDistributiveMapped<T, K extends PropertyKey> = T extends unknown
? { [P in keyof T as P extends K ? never : P]: T[P] }
: never;
And its usage is identical to the previous solution:
type EventWithoutCorrelation = OmitDistributiveMapped<ServerEvent, 'correlationId'>;
// This produces the exact same, correct result.
While slightly more complex to read, this pattern is incredibly powerful and is the foundation for how TypeScript’s own `Omit` is implemented internally. Understanding it provides deeper insight into TypeScript’s type manipulation capabilities.
Comparison of Solutions
Choosing the right solution depends on your specific context, such as the complexity of your types and the need for reusability.
| Solution | Readability | Reusability & Maintainability | Best Use Case |
| 1. Manual Re-Unioning | High (Very explicit) | Low (Requires manual updates) | Quick, one-off transformations on a small, stable union. |
| 2. `DistributiveOmit` Utility | Medium (Requires understanding of distributive conditionals) | High (Set-and-forget, fully automatic) | The default, idiomatic solution for any project. Put it in a shared `types.ts` file. |
| 3. Key Remapping Mapped Type | Low (Combines multiple advanced concepts) | High (Also fully automatic) | When you need a deep understanding or want to build more complex type transformations. |
🤖 Frequently Asked Questions
âť“ Why does TypeScript’s `Omit` utility type fail with discriminated unions?
The `Omit` utility is not distributive; it attempts to operate on the intersection of properties common to all members of the union, rather than applying the operation to each individual member. This often results in the `never` type due to type conflicts and ambiguity.
âť“ How do the different solutions for `Omit` on discriminated unions compare?
Manual re-unioning is explicit but not scalable. The `DistributiveOmit` utility type, using distributive conditional types, is the preferred, reusable, and maintainable solution. Key remapping with mapped types offers a more advanced, equally automatic, but less readable alternative for complex transformations.
âť“ What is a common implementation pitfall when trying to omit properties from a discriminated union?
A common pitfall is directly applying `Omit
Leave a Reply