On this page · sections
Type-Safe API & Library Design
End-to-end type safety spans React component APIs, HTTP contracts, and published library surfaces. The goal is the same everywhere: relationships between inputs and outputs are enforced at compile time, and external data is validated at runtime before it enters typed code.
| Layer | Responsibility | Key pattern |
|---|---|---|
| Component props | Prop relationships, polymorphic as | Discriminated unions, generics |
| Request DTO | Method, path, body, query | Explicit per-endpoint types |
| Response DTO | Success payload per status | Result union (ok / error) |
| Runtime boundary | External JSON | unknown → schema → typed DTO |
| Library public API | Stable exports, inference | Generics + narrow surface |
Type-safe component props
Definition
type ButtonProps = {
variant: "primary" | "secondary";
disabled?: boolean;
children: React.ReactNode;
onClick: () => void;
};
function Button({ variant, disabled, children, onClick }: ButtonProps) {
return (
<button
type="button"
data-variant={variant}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}Export props types so consumers can extend them:
export type { ButtonProps };Interview: Prefer
typeorinterfaceconsistently across a design system; export props for composition.
Discriminated component props
When two prop sets cannot coexist, use a union:
type LinkButtonProps =
| { href: string; onClick?: never }
| { href?: never; onClick: () => void };
function LinkButton(props: LinkButtonProps & { children: React.ReactNode }) {
if ("href" in props && props.href) {
return <a href={props.href}>{props.children}</a>;
}
return <button type="button" onClick={props.onClick}>{props.children}</button>;
}
<LinkButton href="/home">Home</LinkButton> // ✅
<LinkButton onClick={() => {}}>Click</LinkButton> // ✅
// <LinkButton href="/" onClick={() => {}}>Bad</LinkButton> // ❌Same discriminated-union discipline applies to API error shapes and UI view state.
Polymorphic as prop
A component that renders as different elements while inferring correct props:
type AsProp<C extends React.ElementType> = { as?: C };
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
type PolymorphicComponentProp<
C extends React.ElementType,
Props = object,
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
type TextProps = { weight?: "normal" | "bold" };
function Text<C extends React.ElementType = "span">({
as,
weight = "normal",
...rest
}: PolymorphicComponentProp<C, TextProps>) {
const Component = as ?? "span";
return <Component data-weight={weight} {...rest} />;
}
<Text>Default span</Text>
<Text as="p" className="lead">Paragraph</Text>
<Text as="a" href="/docs">Link with href</Text>
// <Text as="a">Missing href</Text> // ❌Interview: Polymorphic components require
ComponentPropsWithoutRefand carefulOmitto avoid prop collisions.
Generic data-driven components
type Column<T> = {
key: keyof T;
header: string;
render?: (value: T[keyof T], row: T) => React.ReactNode;
};
function DataTable<T extends Record<string, unknown>>({
rows,
columns,
}: { rows: T[]; columns: Column<T>[] }) {
return (
<table>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
{columns.map((col) => (
<td key={String(col.key)}>
{col.render ? col.render(row[col.key], row) : String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}key: keyof T binds accessors to the row type. Wrap native elements with ComponentPropsWithoutRef<"input"> to forward valid attributes without re-listing them.
Request and response types
Model each endpoint with explicit input/output types — generated from OpenAPI or hand-maintained:
type GetUserParams = { userId: string };
type UserDto = {
id: string;
name: string;
email: string;
};
type GetUserResponse = { user: UserDto };
type UpdateUserBody = Partial<Pick<UserDto, "name" | "email">>;
type UpdateUserResponse = { user: UserDto };Interview: DTOs describe wire format; map to domain models in the client if naming differs from UI needs.
Typed API client
type ApiError = { code: string; message: string };
type ApiResult<T> =
| { ok: true; data: T; status: number }
| { ok: false; error: ApiError; status: number };
async function request<T>(method: string, path: string, body?: unknown): Promise<ApiResult<T>> {
try {
const res = await fetch(path, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const payload: unknown = await res.json().catch(() => null);
if (!res.ok) {
return { ok: false, error: { code: "HTTP_ERROR", message: res.statusText }, status: res.status };
}
return { ok: true, data: payload as T, status: res.status };
} catch {
return { ok: false, error: { code: "NETWORK", message: "Request failed" }, status: 0 };
}
}
const result = await request<GetUserResponse>("GET", "/api/users/1");
if (result.ok) console.log(result.data.user.name);Status-specific responses and runtime validation
Narrow API errors on status like discriminated component props. Pair compile-time DTOs with runtime schema validation:
const UserDtoSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email() });
type UserDto = z.infer<typeof UserDtoSchema>;
async function getUserSafe(userId: string): Promise<ApiResult<UserDto>> {
const result = await request<unknown>("GET", `/api/users/${userId}`);
if (!result.ok) return result;
const parsed = UserDtoSchema.safeParse(result.data);
if (!parsed.success) {
return { ok: false, status: result.status, error: { code: "INVALID_RESPONSE", message: "Schema mismatch" } };
}
return { ok: true, data: parsed.data, status: result.status };
}Interview: TypeScript is compile-time; Zod / Valibot at the boundary give runtime safety for external data.
Endpoint map pattern
Central maps prevent path typos and keep request/response types co-located:
type ApiRoutes = {
"GET /users/:id": {
params: { id: string };
response: UserDto;
};
"PATCH /users/:id": {
params: { id: string };
body: UpdateUserBody;
response: UserDto;
};
};
// Client codegen or a thin typed wrapper indexes ApiRoutes by method + pathScales well in teams — OpenAPI codegen produces the same structure automatically.
Typed hooks and library design
function useUser(userId: string) {
return useQuery({
queryKey: ["user", userId],
queryFn: async () => {
const result = await getUserSafe(userId);
if (!result.ok) throw new ApiClientError(result.error, result.status);
return result.data;
},
});
}Published libraries should export a narrow public surface (export type { ButtonProps }), use generics with sensible defaults, and semver major bumps for breaking type changes. Add optional fields for backwards compatibility; use @deprecated JSDoc before removing exports.
Senior Interview Summary
What to say
Type-safe design uses discriminated unions for mutually exclusive props and errors, generics for data-driven UI,
ComponentPropsWithoutReffor native extension, andApiResult<T>with runtime schema validation at HTTP boundaries. Library APIs export a narrow surface with stable generics.
Layers
Components → props union + generic T + polymorphic as
API DTOs → per-endpoint request/response types
Typed client → ApiResult<T> + status narrowing
Schema parse → unknown → Zod → typed DTO
Hooks / UI → narrowed success types only
Libraries → export types + minimal public APIAnti-patterns
props: any → defeats component safety
response.json() as User everywhere → no runtime guarantee
string column keys on tables → typos slip through
optional every field on API types → hides contract breaks
Spreading {...props} without Omit → polymorphic prop collisionsPractical rule
Design system → polymorphic + ComponentPropsWithoutRef
Feature UI → concrete props + discriminated variants
External JSON → unknown → schema → typed DTO
Shared library → narrow exports, semver for type breaks