skip to content

You apply `Omit<Shape, "id">` in TypeScript where `Shape` is a union of object types, and the result collapses into a single flat type instead of a union. Why, and how do you keep the variants?

level: seniorimportance: should knowfreq 42%

answer

  1. keyof over a union is the shared keys
  2. Omit sees one type, not many
  3. payload properties disappear
  4. narrowing stops working
  5. evaluate per member instead

basics

~20 s

Omit applies to the union as a whole, not member by member. It computes keyof Shape, which is only the keys every member shares, then picks from that — so member-specific properties vanish. A distributive wrapper that maps Omit over each member preserves the variants.

solid answer

~50 s

`Omit<T, K>` expands to `Pick<T, Exclude<keyof T, K>>`, and `keyof` over a union yields only the keys common to *every* member. So for a union of `{ kind: "circle"; r: number; id: string }` and `{ kind: "square"; side: number; id: string }`, `keyof Shape` is just `"kind" | "id"`; remove `"id"` and you are left picking `"kind"` — a single object type `{ kind: "circle" | "square" }`. Every variant-specific property is gone, and with it the ability to narrow on the discriminant. The fix is a helper that applies Omit to each member separately: `type DistributiveOmit<T, K extends keyof any> = T extends unknown ? Omit<T, K> : never`. Because `T` appears bare on the left of the conditional, the compiler evaluates it once per union member and unions the results back, so you get a union of trimmed variants.

code

typescript · 15 lines
typescript
type Shape =
  | { kind: "circle"; r: number; id: string }
  | { kind: "square"; side: number; id: string };

// keyof Shape is only "kind" | "id", so this collapses:
type Flat = Omit<Shape, "id">; // { kind: "circle" | "square" }

type DistributiveOmit<T, K extends keyof any> =
  T extends unknown ? Omit<T, K> : never;

// { kind: "circle"; r: number } | { kind: "square"; side: number }
type Kept = DistributiveOmit<Shape, "id">;

const c: Kept = { kind: "circle", r: 2 };
console.log(c.kind);

go deeper

for a junior

Recognise the symptom: applying Omit to a union of object types gives you back one flat type with the variant-specific properties missing. Knowing it happens is enough here.

for a middle

Explain the two rules that produce it — keyof over a union yields only shared keys, and Omit is defined as Pick<T, Exclude<keyof T, K>> — and walk the expansion on a concrete union.

for a senior

Show the DistributiveOmit wrapper, say why the bare type parameter makes it evaluate per member, and describe the downstream damage: narrowing and exhaustiveness quietly stop working.

for a principal

Weigh whether the codebase should carry distributive wrappers as house utilities or whether a union that needs trimming is a modelling smell. Own the readability cost of non-standard type helpers that every reader must learn.

## What you observe Start with a union of object types that share a tag: ```ts type Shape = | { kind: "circle"; r: number; id: string } | { kind: "square"; side: number; id: string }; type Flat = Omit<Shape, "id">; ``` You expect `Flat` to be a union of two trimmed variants. What you actually get is `{ kind: "circle" | "square" }` — one object type, both payload properties gone, and no way to narrow to a variant any more. Hover it in the editor and the surprise is immediate. ## Why it happens Two separate rules combine. **First: `keyof` over a union is an intersection of key sets.** A value of type `A | B` is guaranteed to have only the properties that *both* `A` and `B` have — you cannot safely read `r` from a value that might be the square. So `keyof (A | B)` is `keyof A & keyof B`. Here that is `"kind" | "id"`. `r` and `side` are simply not in it. **Second: `Omit` consumes `keyof T` directly.** Its definition is `Pick<T, Exclude<keyof T, K>>`. It computes the union's shared keys, subtracts `"id"`, and picks `"kind"` out of the union. `Pick` over a union of `T` gives each property the union of its types across members, so `kind` becomes `"circle" | "square"` and the result is a single object type. Nothing here is a special case for unions — it is the definition executing literally. The mental model to carry away is that `Omit` treats its input as one type, and any type-level operation that goes through `keyof T` will flatten a union the same way. ## Why it matters more than it looks The collapsed type is not merely narrower; it is *wrong in a way the checker cannot help with*. The whole value of a discriminated union is that checking the tag tells the compiler which payload is present. After the collapse, `kind` is still a literal union but there is no payload attached to either branch, so a `switch` on `kind` narrows to nothing useful and exhaustiveness checks lose their meaning. Code that used to be safe now needs assertions to compile, and assertions are exactly where unsound code enters. This bites most often in component props and in "same shape minus the server-assigned fields" input types, where the source really is a union of variants and the trim is meant to apply to each. ## The fix Apply `Omit` to one member at a time. A conditional type whose checked type is a bare type parameter is *distributive*: the compiler evaluates it separately for each member of a union and unions the results. ```ts type DistributiveOmit<T, K extends keyof any> = T extends unknown ? Omit<T, K> : never; type Kept = DistributiveOmit<Shape, "id">; // { kind: "circle"; r: number } | { kind: "square"; side: number } ``` `T extends unknown` is a condition every type satisfies — its only job is to put `T` in the distributive position. Inside the true branch, `T` is one member, so `keyof T` is that member's full key set and the trim behaves as you wanted. The same wrapper shape works for `Pick`. The cost is a helper in your codebase that readers must recognise, and a name that does not appear in the standard library. Many teams define both `DistributiveOmit` and `DistributivePick` once in a `types` module and treat the plain versions as the exception. ## Alternatives worth knowing Sometimes the better answer is not to trim a union at all. If the union members already differ, defining each trimmed variant explicitly and unioning them is more readable than any helper, and the explicit version documents intent. If the field you are removing is on every member and nothing else varies structurally, consider whether the union should have been one type with a discriminated payload property instead — a modelling fix rather than a type-level one. And if what you actually want is *the union member that has a given tag*, that is a filtering operation over the union rather than a property trim; reaching for `Omit` there is a sign the model and the operation are mismatched. ## How to answer this under interview pressure Say the two rules, in order: `keyof` over a union gives shared keys only, and `Omit` is defined through `keyof T`. Then show the distributive wrapper and name why the bare type parameter is what makes it work. If you can also say what breaks downstream — narrowing and exhaustiveness — you have shown you have hit this in production rather than read about it.

  • Why does writing `T extends unknown ? ... : never` change the outcome when every type satisfies `unknown`?
    The condition is irrelevant; the position of `T` is not. A conditional type whose checked type is a bare type parameter is evaluated once per union member and the results are unioned back. Putting `T` there is what splits the union, so inside the true branch `keyof T` sees a single member's full key set instead of the shared subset.
  • Does `Pick` collapse a union the same way?
    Yes — for the same reason. `Pick<T, K>` still constrains `K` to `keyof T`, which over a union is only the shared keys, so you cannot even name a variant-specific property. A `DistributivePick` wrapper built the same way fixes it, and in practice teams define both helpers together.
  • What breaks downstream once the union has collapsed?
    Narrowing. The tag survives as a literal union but no payload is attached to either branch, so switching on it no longer tells the compiler which properties exist, and exhaustiveness checks stop carrying information. Code that was safe starts needing assertions, which is where unsound access creeps in.

saying these in an interview costs you the question

  • Saying keyof over a union returns the union of all members' keys
  • Claiming Omit distributes over unions like a conditional type does
  • Blaming the collapse on the discriminant being a literal type
  • Fixing it with an assertion instead of a per-member helper
  • Thinking a compiler flag restores the variants

context