skip to content

Given `type Action = { type: 'add'; payload: number } | { type: 'remove'; id: string } | { type: 'reset' }`, how do you write a TypeScript type for just the `'add'` variant — and for its payload — without repeating the shape?

level: middleimportance: should knowfreq 52%

answer

  1. filter by the discriminant only
  2. structural assignability picks the member
  3. indexed access reads the field type
  4. constrain the tag parameter
  5. Exclude gives the complement

basics

~20 s

Use Extract with a partial shape as the filter: Extract<Action, { type: 'add' }> is the add variant, and Extract<Action, { type: 'add' }>['payload'] is its payload type. Nothing is duplicated, so the types follow the union.

solid answer

~40 s

`Extract<Action, { type: 'add' }>` gives you the `{ type: 'add'; payload: number }` member on its own. The filter object only has to be a type the target member is assignable to, so naming the discriminant is enough — you never restate `payload`. From there, an indexed access gets the field: `Extract<Action, { type: 'add' }>['payload']` is `number`. If you need this for several tags, wrap it once as a generic helper — `type ActionOf<K extends Action['type']> = Extract<Action, { type: K }>` — which also makes a misspelled tag a compile error, because `K` is constrained to the real tag union. The big win is that everything stays derived: adding a field to the `'add'` variant updates every type built from it, with no second place to edit.

code

typescript · 18 lines
typescript
type Action =
  | { type: 'add'; payload: number }
  | { type: 'remove'; id: string }
  | { type: 'reset' };

type ActionOf<K extends Action['type']> = Extract<Action, { type: K }>;

type AddAction = ActionOf<'add'>; // { type: 'add'; payload: number }
type AddPayload = AddAction['payload']; // number
type WithData = Exclude<Action, { type: 'reset' }>; // add | remove

type Handlers = { [K in Action['type']]: (action: ActionOf<K>) => void };

const handlers: Handlers = {
  add: (a) => console.log(a.payload),
  remove: (a) => console.log(a.id),
  reset: () => {},
};

go deeper

for a junior

Know that Extract can pull one member out of a union of object types by matching on its tag, and that you only need to name the discriminant in the filter, not the whole shape.

for a middle

Explain why structural assignability makes a partial filter object match, and combine Extract with an indexed access to reach a field type such as the payload.

for a senior

Build the derived helper the codebase actually needs — a constrained ActionOf alias and a handler map keyed on the tag union — so adding a variant breaks compilation in exactly the places that must change.

for a principal

Decide how much of a domain's type surface should be derived from one union versus declared explicitly. Derivation kills drift but concentrates blast radius, so pair it with type tests and a clear convention for where the source of truth lives.

## The problem Tagged unions (a set of object types sharing a literal field that says which one you have) are the standard way to model actions, events, and results in TypeScript. Downstream code frequently needs *one* member: a handler that only accepts `'add'` actions, a test factory that builds one, a function that takes just the payload. Writing that shape out again duplicates the source of truth, and the copy drifts the first time someone adds a field. ## Extract with a partial filter ```ts type Action = | { type: 'add'; payload: number } | { type: 'remove'; id: string } | { type: 'reset' }; type AddAction = Extract<Action, { type: 'add' }>; // { type: 'add'; payload: number } ``` This works because `Extract` keeps the members **assignable to** the filter, and assignability for object types is structural: `{ type: 'add'; payload: number }` is assignable to `{ type: 'add' }` since it has that property with a compatible type and extra properties are fine. The `'remove'` and `'reset'` members are not, because their `type` fields are different literals. So the discriminant alone identifies the member, and you never mention `payload` in the filter. ## Getting at the fields Once you have the member, an indexed access reads a property type off it: ```ts type AddPayload = Extract<Action, { type: 'add' }>['payload']; // number type AnyTag = Action['type']; // 'add' | 'remove' | 'reset' ``` `Action['type']` is worth knowing on its own: reading a property off a *union* gives the union of that property's types across members, which is exactly the set of valid tags. ## The reusable helper Repeating `Extract<Action, { type: ... }>` at a dozen call sites is noise. One generic alias removes it and adds a safety net: ```ts type ActionOf<K extends Action['type']> = Extract<Action, { type: K }>; type Remove = ActionOf<'remove'>; // { type: 'remove'; id: string } // type Oops = ActionOf<'removee'>; // Error: 'removee' is not assignable to 'add' | 'remove' | 'reset' ``` The constraint `K extends Action['type']` is doing real work. Bare `Extract` accepts any filter at all, so a typo just quietly produces `never`; constraining `K` to the tag union turns the same typo into a compile error pointing at the exact spot. Any time you build a tag-driven helper, constrain the tag. Because `K` may itself be a union, the helper composes: `ActionOf<'add' | 'reset'>` is the union of those two members, which is a natural way to type a function that handles a subset. ## Typing a handler map The pattern's real payoff is keeping a dispatch table honest: ```ts type Handlers = { [K in Action['type']]: (action: ActionOf<K>) => void; }; const handlers: Handlers = { add: (a) => console.log(a.payload), remove: (a) => console.log(a.id), reset: () => {}, }; ``` Each handler receives exactly its own variant, so `a.payload` is available in `add` and `a.id` in `remove`, with no narrowing code and no casts. Add a fourth variant to `Action` and the object literal immediately errors for the missing key. ## Exclude as the complement The mirror operation is just as useful: `Exclude<Action, { type: 'reset' }>` gives the actions that carry data, so you can type a logger that only accepts those. Since `Exclude` and `Extract` partition the union, one of the two always expresses the subset you want. ## What this does not do All of this is erased. `Extract<Action, { type: 'add' }>` produces no runtime check; a value still has to be tested with something like `action.type === 'add'` before you can safely read `payload` at run time. The utility type describes the subset for the checker — deciding which variant you are actually holding is a separate, runtime job. One more caveat: the filter object is matched structurally, so a loose filter can match more members than you intended. `Extract<Action, { type: string }>` matches all three, because every tag is assignable to `string`. Keep the filter as specific as the discriminant literal.

  • Why does the filter object `{ type: 'add' }` match the member even though that member has an extra `payload` property?
    Because `Extract` tests assignability, which is structural: a type with all the required properties plus more is assignable to the smaller one. Excess properties only get rejected in the special case of a fresh object literal assigned to a variable, which is not what is happening at the type level here.
  • What happens if the tag in the filter is misspelled, such as `Extract<Action, { type: 'ad' }>`?
    No member matches, so every branch yields `never` and the result is `never`. There is no error at the point of the typo — the failure appears later, wherever you try to use the type. Constraining the tag through a helper such as `ActionOf<K extends Action['type']>` moves the error back to the typo itself.
  • How do you get the union of every tag out of the union type itself?
    An indexed access on the union: `Action['type']` yields `'add' | 'remove' | 'reset'`. Reading a property off a union produces the union of that property's types across all members, which gives a tag list that can never drift from the variants.
  • Can you extract more than one variant at once?
    Yes — pass a union as the filter, `Extract<Action, { type: 'add' } | { type: 'reset' }>`, or use a union tag through a helper, `ActionOf<'add' | 'reset'>`. Each member is tested against the whole filter, so any member matching either shape survives.

saying these in an interview costs you the question

  • Retyping the variant shape instead of deriving it
  • Expecting Extract to narrow a value at run time
  • Using a loose filter like { type: string } that matches everything
  • Assuming excess properties make the filter fail
  • Leaving the tag parameter unconstrained so typos yield never

context