How do you use TypeScript to make a component's props hard to misuse?
Model the component's legal states as a discriminated union so contradictory prop combinations do not typecheck, use a type parameter to tie related props like items and renderItem together, and keep any out of the public surface because one any erases the narrowing everything else depends on.
What the interviewer is scoring
- Does the candidate reach for a union of legal shapes rather than a bag of optional props
- Whether they can name the discriminant and explain why it has to be a literal type
- That they understand generics here are about preserving a relationship between two props, not about reuse for its own sake
- Whether they distinguish any from unknown and can say what each does to the caller
- Does the candidate use never to make adding a variant a compile error rather than a silent no-op
Answer
Start from the states the component can legally be in
Before typing anything, write down the combinations of props that make sense and the combinations that do not. An action element is either a button with an onClick or a link with an href; it is never both and never neither. A data table is either loading, empty, in error with a message, or holding rows. Once that list exists, the typing work is mechanical: each legal state becomes one member of a union, and the illegal combinations stop being something you document and start being something that does not compile.
The alternative, which is what most component libraries end up shipping, is a single object type where every state-specific prop is optional. That type says a link with no href and a simultaneous onClick is fine, so the only thing preventing it is a comment and a reviewer's attention.
A union of shapes, discriminated by a literal
type Common = { children: React.ReactNode; disabled?: boolean };
// `as` is the discriminant: a literal type, so narrowing on it is exhaustive.
type Variant =
| { as: 'button'; onClick: () => void; type?: 'button' | 'submit' }
| { as: 'link'; href: string; newTab?: boolean };
export function Action(props: Common & Variant) {
if (props.as === 'link') {
// props.href is string here. props.onClick is not in scope at all.
return <a href={props.href} target={props.newTab ? '_blank' : undefined}>{props.children}</a>;
}
return <button type={props.type ?? 'button'} onClick={props.onClick}>{props.children}</button>;
}
The discriminant has to be a literal or a literal union, because narrowing works by comparing against a value the compiler can enumerate. If as were typed string, the equality check tells the compiler nothing and both branches keep the full union. Since TypeScript 4.6 you may destructure the discriminant in the parameter list and still get narrowing on the sibling properties, which is what makes this pattern pleasant to use rather than merely correct.
Note what the caller experiences. as="link" with an onClick is now an excess-property error, and as="link" with no href is a missing-property error. Neither needed a runtime check or a line of documentation.
Generics preserve a relationship, not just a shape
A generic on a component is worth adding when two props have to agree with each other and the component itself does not care what they agree on.
type SelectProps<T> = {
items: readonly T[];
value: T | null;
onChange: (next: T) => void;
getKey: (item: T) => string;
renderItem: (item: T) => React.ReactNode;
};
export function Select<T>({ items, value, onChange, getKey, renderItem }: SelectProps<T>) {
// T is inferred from `items` at each call site, so `renderItem` receives the
// caller's own element type and `onChange` hands back that same type.
return (
<ul>
{items.map((item) => (
<li key={getKey(item)} aria-selected={item === value} onClick={() => onChange(item)}>
{renderItem(item)}
</li>
))}
</ul>
);
}
T is inferred, so nobody writes it. Its whole value is the constraint it imposes: a caller passing User[] cannot write a renderItem expecting an Invoice, and onChange is typed (next: User) => void without anyone annotating it. Constrain the parameter only where the component genuinely depends on structure — <T extends { id: string }> if you derive the key yourself — because every constraint is a caller you have excluded.
What one any does to all of that
Typing renderItem: (item: any) => React.ReactNode looks like a small concession. It is not, because any is not "a value of unknown type", it is an instruction to stop checking. The item inside that callback accepts any property access, so misspellings and removed fields survive a rename; and because any is assignable in both directions, the compiler will happily let that value flow onwards into typed code and corrupt inference there too. The generic still exists, but the relationship it was created to enforce is gone in the one place callers actually write code.
unknown is the honest version of the same admission. It accepts anything on the way in and permits nothing on the way out until you narrow it, so the obligation to check lands on the person who has the information. Use it for genuinely opaque payloads, and reserve any for the interior of a deliberately unsafe adapter with a comment saying why.
Optional props are not a union
The distinction that separates a strong answer here is that a bag of optional props and a discriminated union are not two styles of the same thing. Optional props widen the set of accepted inputs; a union narrows it. If you type variants as { variant?: 'primary' | 'danger'; href?: string; onClick?: () => void } you have written a type that permits every contradiction you were trying to prevent, and the component body then needs runtime guards and a fallback branch for states the design never had. Candidates who have felt this usually mention the second half too: once the shape is a union, add an exhaustiveness check so that adding a ninth variant fails the build rather than silently falling through.
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
// In a switch over props.as, `default: return assertNever(props)` stops
// compiling the moment a new member is added to the union.
A prop type is an API contract, and the test of a good one is whether a caller who has not read the documentation can still write something wrong that compiles.
Likely follow-ups
- Your union has eight variants and the render function is a wall of branches. When is that a typing problem and when is it a component-boundary problem?
- How would you type a polymorphic component that renders as whatever element the caller passes in the as prop?
- What does the satisfies operator give you that a type annotation on the same object does not?
- A colleague types a callback prop as (e: any) => void to silence an error. What breaks downstream that they will not see?
Related questions
- What does the compiler erase from a generic type, and how do you decide between ? extends and ? super?mediumAlso on generics4 min
- Your endpoint takes a typed request body and it still crashed on a missing field. How is that possible when it compiled?mediumAlso on typescript4 min
- How do you use TypeScript's type system to make invalid states unrepresentable in a service?mediumAlso on discriminated-unions4 min
- Where does TypeScript stop protecting you, and what do you do at those points?hardAlso on typescript4 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardSame kind of round: concept7 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardSame kind of round: concept6 min
- How do you decide between BFS and DFS - and how do you recognise a graph problem that nobody described as a graph?mediumSame kind of round: coding4 min
- How do goroutines leak, and how would you find a leak in a running service?mediumSame kind of round: concept5 min