Topics
v5.0.0
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.

LayerResponsibilityKey pattern
Component propsProp relationships, polymorphic asDiscriminated unions, generics
Request DTOMethod, path, body, queryExplicit per-endpoint types
Response DTOSuccess payload per statusResult union (ok / error)
Runtime boundaryExternal JSONunknown → schema → typed DTO
Library public APIStable exports, inferenceGenerics + 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 type or interface consistently 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 ComponentPropsWithoutRef and careful Omit to 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 + path

Scales 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, ComponentPropsWithoutRef for native extension, and ApiResult<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 API

Anti-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 collisions

Practical 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

Last updated on