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?
answer
- filter by the discriminant only
- structural assignability picks the member
- indexed access reads the field type
- constrain the tag parameter
- Exclude gives the complement
basics
~20 sUse 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 linestype 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
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.
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.
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.
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