How do you use TypeScript to make a component's props hard to misuse?
A safe TypeScript component props API models legal states with discriminated unions, ties related props together with generics and keeps any out of the public surface.
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
Short answer
Make misuse impossible by typing only the legal prop combinations. Use discriminated unions for mutually exclusive states, generics when one prop depends on another, and never checks so a new variant cannot be added silently.
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.
© 2026 Preptima. Originally published at preptima.com.
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
- How do you make the compiler fail when somebody adds a new variant to a union you switch on?mediumAlso on typescript and discriminated-unions5 min
- What does the compiler erase from a generic type, and how do you decide between ? extends and ? super?mediumAlso on generics5 min
- You pass an object with extra properties to something typed to accept fewer. When does TypeScript complain, and when does it not?mediumAlso on typescript5 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