In a TypeScript API layer you find UserResponse, OrderResponse and InvoiceResponse, each repeating `status` and `requestId` and differing only in the type of `data`. How would you consolidate them into one declaration, and when is a single generic declaration the wrong answer?
answer
- identical except in one position
- name the varying slot, share the rest
- keep old names as aliases
- different fields means a different shape
basics
~20 sParameterize the varying slot: declare one ApiResponse<T> with status, requestId and data of type T, then use ApiResponse<User>, ApiResponse<Order> and so on. It is the wrong model when the variants differ in which fields exist, not just in one field's type.
solid answer
~50 sI would write one declaration — `interface ApiResponse<T> { status: number; requestId: string; data: T }` — and keep the old names as thin aliases, `type UserResponse = ApiResponse<User>`, so call sites need no edit and the change is purely additive. The win is that the envelope is now written once: adding `traceId` is a one-line change, and helper code like an unwrap function can be typed `<T>(res: ApiResponse<T>) => T` and work for every endpoint. It is the wrong model when the variants are not the same shape with a different slot — an error envelope that has `error` and no `data` is a different shape, and forcing it through the same parameter produces `data: SomeType | undefined` that every consumer must re-check. That family wants a union with a discriminant instead. It is also the wrong tool when the parameter would appear exactly once and relate nothing to anything else.
code
typescript · 21 linesinterface User { id: string }
interface Order { id: string; total: number }
// One envelope, parameterized in the slot that varies
interface ApiResponse<T> {
status: number;
requestId: string;
data: T;
}
// Old names survive as thin aliases: no call site has to change
type UserResponse = ApiResponse<User>;
type OrderResponse = ApiResponse<Order>;
// The relation between envelope and payload is now expressible once
function unwrap<T>(res: ApiResponse<T>): T {
return res.data;
}
const res: UserResponse = { status: 200, requestId: "r1", data: { id: "u1" } };
const user = unwrap(res); // Usergo deeper
Spot the duplication and know the mechanical move: one declaration with a type parameter in the field that varies, used as ApiResponse<User> at each call site.
Explain what the parameter buys beyond less typing — helpers whose return type follows the argument, and a single place to add an envelope field — and why any would throw that away.
Show the judgment: consolidate only when the shapes are identical apart from one slot, keep old names as aliases so the refactor is type-only, and reach for a discriminated union when the fields themselves differ.
Own the coupling decision. A shared envelope makes one declaration a cross-team contract, so weigh the consistency it enforces against the blast radius of changing it, and resist parameterizing slots no caller actually varies.
## Reading the duplication ```typescript interface UserResponse { status: number; requestId: string; data: User } interface OrderResponse { status: number; requestId: string; data: Order } interface InvoiceResponse { status: number; requestId: string; data: Invoice } ``` The tell is that the declarations are *identical except in one position*. That is precisely the condition a type parameter exists for: it names the varying slot and shares everything else. Contrast it with three interfaces that differ in several places — those are not instances of one pattern and should not be forced into one. ## The consolidation ```typescript interface ApiResponse<T> { status: number; requestId: string; data: T; } type UserResponse = ApiResponse<User>; type OrderResponse = ApiResponse<Order>; ``` Keeping the old names as aliases matters in a real codebase: every existing annotation keeps compiling, review diffs stay small, and domain-specific names remain available where they read better. Because the whole thing is erased, this is a pure type-layer refactor with no emitted-code change at all. ## What you actually gain **One place to evolve.** Adding `traceId: string` is one line instead of N, and no endpoint can be forgotten. **Shared helpers become expressible.** Before, a helper that pulls the payload out has to be written per type or typed loosely. After, the relationship is in the type system: ```typescript function unwrap<T>(res: ApiResponse<T>): T { return res.data; } ``` `unwrap(userRes)` is `User`, `unwrap(orderRes)` is `Order`. That relation between input and output is the thing duplication cannot express and `any` throws away. **Consistency is enforced.** A new endpoint cannot quietly spell the field `reqId`; it instantiates the same declaration or it does not compile. ## When a single generic is the wrong answer **The variants differ structurally.** If success carries `data` and failure carries `error` with no `data`, they are two shapes, not one shape with a hole. Squeezing them together yields `data: T | undefined; error?: ApiError`, which pushes an unnecessary check into every consumer and lets the impossible combination `data` *and* `error` typecheck. A parameterized union expresses the real contract: ```typescript type Result<T, E> = | { ok: true; value: T } | { ok: false; error: E }; ``` Here the parameter still earns its place — it varies the payload of each branch — but the *shape* variation is carried by the union, not by the parameter. **The parameter relates nothing.** A type parameter is worth having when it ties two or more positions together (a field to a method's argument, an input to an output). One that appears in a single position and nowhere else is decoration; the field could simply have been annotated directly. **Over-parameterization.** `ApiResponse<TData, TMeta, TError, TLinks>` is technically shareable and practically unreadable: every call site must now decide four arguments, and most of them do not care. Parameterize the slot that genuinely varies across callers and fix the rest. **The envelopes were only accidentally alike.** If two teams happen to have converged on the same three fields but own the contracts independently, coupling them to one declaration means one team's change breaks the other. Duplication is sometimes the cheaper coupling. ## Migration mechanics Because the change is type-only, it can land in one commit with no runtime risk: introduce `ApiResponse<T>`, redefine the old names as aliases, run the type-checker, and delete the aliases opportunistically afterwards. There is no serialization change, no version negotiation, and nothing to feature-flag — the emitted JavaScript is byte-identical. ## What interviewers listen for They are checking whether you reach for parameterization at the right moment and, more importantly, whether you know its limit. Candidates who answer only "make it generic" have shown half the judgment; the other half is naming the case where the shapes genuinely differ and a union is the honest model. Mentioning the alias-preserving migration signals that you have done this on a codebase with call sites, not just on a whiteboard.
- Why keep UserResponse and OrderResponse as aliases instead of rewriting every call site?It makes the refactor type-only and reviewable: existing annotations keep compiling, the diff is three added lines rather than hundreds of edits, and domain names stay available where they read better than `ApiResponse<User>`. You can retire the aliases later, or keep them — either way nothing in the emitted JavaScript changes.
- How do you decide a type parameter is pulling its weight rather than being decoration?It should tie at least two positions together — a field to a method argument, or an input to a return type — so the checker can propagate a caller's choice. A parameter that appears in exactly one position adds a decision at every use site and buys nothing that a direct annotation would not.
- Success and failure envelopes have different fields. Why is one generic envelope worse than a union there?Because a single envelope has to admit both, so `data` becomes optional and the impossible state — `data` and `error` both present — typechecks. A union of two branches makes each branch exactly what it is, so consumers check one field and the checker gives them the right shape in each arm.
saying these in an interview costs you the question
- Types the shared field as any instead of a parameter
- Adds a parameter for every field just in case
- Forces incompatible shapes through one generic envelope
- Says the refactor needs a versioned rollout
- Thinks the consolidation changes the emitted JavaScript