You maintain a shared TypeScript types package. Whether an exported generic type distributes over unions is invisible in its signature — how do you decide which behaviour a type should have, and how do you keep changing it from silently breaking consumers?
answer
- types are a public contract too
- no runtime signal, no test failure
- transform per member or judge the whole
- assert union, never and any
- flipping it is a major release
basics
~20 sDecide by intent: per-member transforms should distribute, whole-type judgments should not. Then pin the choice with type-level tests that include union, never and any inputs, encode it in the name, and treat flipping it as a breaking release.
solid answer
~50 sI decide from intent. A type that *transforms each variant* of a model — stripping a key, wrapping members, filtering — should distribute, because collapsing a union destroys narrowing for every consumer. A type that *judges a whole type* — an equality or shape predicate — must not distribute, or it returns two answers at once and shows up as `boolean` or a union of both branches. Then I make the choice explicit rather than incidental: name the distributing variant (`DistributiveOmit`), and pin the behaviour with type-level tests that assert exact results for a union input, a `never` input and an `any` input, since those three are where distribution is observable. Flipping the behaviour later is a breaking change with no error at the definition site and no runtime signal, so it goes in a major release — or, better, ships as a second exported type while the old one stays.
code
typescript · 13 linestype Expect<T extends true> = T;
type Equal<A, B> =
(<G>() => G extends A ? 1 : 2) extends (<G>() => G extends B ? 1 : 2)
? true
: false;
type Strip<T> = T extends unknown ? Omit<T, 'id'> : never;
type A = { kind: 'a'; id: string; x: number };
type B = { kind: 'b'; id: string; y: number };
type _Union = Expect<Equal<Strip<A | B>, Strip<A> | Strip<B>>>;
type _Never = Expect<Equal<Strip<never>, never>>;go deeper
Know that a shared type's behaviour on unions is something consumers depend on, and check how a helper behaves with a union before reusing it across a codebase.
Be ready to demonstrate the difference concretely with a union, never and any input, and to write a small type-level assertion that fails when the result changes.
Expect to argue for type tests in CI and to diagnose a consumer-side regression that traces back to a helper whose distribution changed, with no runtime symptom to guide you.
Own the policy: which behaviour each exported type has, how it is named, that changing it is a major release, and that emitted declarations rather than source are what consumers compile against.
## Why this is a real API question An exported generic type has a public contract that its signature does not state. `type Strip<T> = ...` tells a consumer nothing about what happens when they pass `A | B`, `never`, or a value whose type has drifted to `any`. Yet those cases change the resulting type materially — a union preserved or collapsed, a predicate answering `false` or `boolean`, a whole chain of downstream types quietly becoming uninhabited. What makes it worse than an ordinary API decision is the failure mode. Change a function's behaviour and consumers' tests fail. Change a type's distribution behaviour and there is: no error where the type is defined, no runtime signal at all (types are erased), and often no error at the consumer's call site either — just a wider or narrower type that surfaces as a confusing error somewhere else, or as a lost exhaustiveness check that stops catching a missing case. ## Deciding: intent, not habit The question to ask of each exported type is whether it is a **map over members** or a **judgment about the whole**. *Map over members* — a transform applied to each variant of a model. Removing a key from a discriminated union, wrapping each member, filtering members out. These must distribute: the union is the thing the consumer relies on, and collapsing it removes narrowing at every downstream `switch`. Implement with a naked `T` in the checked position, and say so in the name. *Judgment about the whole* — is this type exactly `U`, is this type `never`, does this type satisfy some shape. These must not distribute, because distributing turns a single question into one answer per member. The symptom is a predicate that returns `boolean` instead of `true` or `false`, or a helper that returns the union of both of its branches. Implement with the one-element tuple form, `[T] extends [U]`. The genuinely hard cases are helpers used both ways. There the right move is usually two exported types with distinct names rather than one type with a mode flag — a boolean type parameter is exactly the kind of thing that gets passed as a plain `boolean` and then splits into both branches, reintroducing the problem it was meant to solve. ## Pinning it: type-level tests Behaviour that is not asserted is behaviour that will drift. A shared types package needs a test file that exercises the types themselves, checked by the same compiler run as the rest of CI: ```ts type Expect<T extends true> = T; type Equal<A, B> = (<G>() => G extends A ? 1 : 2) extends (<G>() => G extends B ? 1 : 2) ? true : false; type _1 = Expect<Equal<Strip<A | B>, Strip<A> | Strip<B>>>; type _2 = Expect<Equal<Strip<never>, never>>; ``` The important part is not which assertion helper you use — a hand-rolled `Expect`/`Equal` pair, or a typed-test library — but **which inputs the suite covers**. Three inputs are where distribution is observable and must appear in every such suite: - a **union** of at least two members, asserted against the exact expected result; - **`never`**, since a distributive type collapses to `never` and a non-distributive one takes a branch; - **`any`**, since a distributive type returns the union of both branches. A negative test matters too: `@ts-expect-error` on an assignment that must fail proves the type is still narrow, and starts failing loudly if the type widens. ## Releasing it Type-only changes deserve the same semver discipline as runtime changes, and distribution is a behavioural change, not a refactor. Practical policy: - Adding a new exported type is a minor release. Flipping an existing one's distribution is major. - Prefer adding the new variant beside the old one and deprecating in documentation, so consumers migrate on their own schedule rather than at upgrade time. - Test the published `.d.ts` output the same way, not just the source — what consumers actually compile against is the emitted declarations. - Pin the compiler version used in the test suite, and re-run type tests on compiler upgrades; inference details are the kind of thing a toolchain bump can move under you. ## What an interviewer is listening for The strong answer treats types as a public interface with an observable contract, names the specific inputs where the contract is visible, and proposes a mechanism — type-level assertions in CI plus release discipline — rather than "be careful" or "document it". The weak answer treats the choice as a style preference and assumes that because nothing is emitted, nothing can break.
- Which inputs must a type-level test suite cover to pin distribution behaviour?At minimum a union of two members, `never`, and `any`. Those are precisely where distributive and non-distributive definitions disagree: the union is preserved or collapsed, `never` collapses or takes a branch, and `any` returns both branches or one. A suite that only tests a single concrete type passes under either implementation, so it pins nothing.
- Why not expose a boolean type parameter to let callers choose the mode?Because `boolean` is the union `true | false`, so a caller who passes a plain `boolean` rather than a literal makes the flag itself distribute, and the helper returns the union of both modes. Two named exports avoid the whole class of problem and read better at the call site than `Strip<T, true>`.
- How is a distribution change different from an ordinary breaking change?It has no runtime component and often no error at the consumer's call site. Types are erased, so no test exercising behaviour fails; the type merely widens or collapses, and the symptom appears as an unrelated error elsewhere or as a lost exhaustiveness check that silently stops catching missing cases.
saying these in an interview costs you the question
- Assumes type-only changes cannot break consumers
- Documents the behaviour instead of asserting it in CI
- Tests helpers only with a single concrete type
- Flips distribution in a patch release as a refactor
- Adds a boolean type parameter to switch modes