Q213: Typing API Contracts and Runtime Schemas
What Interviewers Want To Evaluate
Interviewers want to know whether you can keep frontend types aligned with backend reality.
They check API contracts, generated types, runtime schemas, unknown data, versioning, error responses, normalization, and where validation should happen in a modern frontend architecture.
Short Interview Answer
API response types should be treated as contracts, but external data should enter the frontend as unknown until validated or generated from a trusted schema. I prefer shared OpenAPI, GraphQL, or schema-generated types when possible. At runtime boundaries, I validate important responses, normalize them into UI-friendly models, and handle versioning and error shapes explicitly.
Detailed Interview Answer
APIs are trust boundaries.
The frontend may be written in TypeScript, but the network is not.
This is unsafe:
const user = (await response.json()) as User;
The cast tells TypeScript what to believe.
It does not inspect the JSON.
Unknown At The Boundary
Use unknown mentally, even when fetch returns any.
async function fetchJson(url: string): Promise<unknown> {
const response = await fetch(url);
return response.json();
}
Then validate or normalize.
function isUser(value: unknown): value is { id: string; name: string } {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
typeof value.id === "string" &&
typeof value.name === "string"
);
}
This keeps runtime reality visible.
Contract Sources
Common contract sources:
OpenAPI
GraphQL schema
RPC router
shared validation schemas
database-generated types
manual TypeScript interfaces
Manual interfaces are easy to start with.
Generated contracts are better when teams and APIs grow.
Generated Types
Generated API types reduce drift.
For example:
backend schema changes
types regenerate
frontend compile fails where contract changed
developer updates UI intentionally
This is healthier than discovering mismatches at runtime.
Generated types still do not always validate runtime responses unless the tooling includes validation.
Runtime Schemas
Runtime schemas validate data and can often infer TypeScript types.
Conceptually:
const UserSchema = schema.object({
id: schema.string(),
name: schema.string(),
role: schema.union(["admin", "member"]),
});
type User = Infer<typeof UserSchema>;
At the boundary:
const parsed = UserSchema.safeParse(await fetchJson("/api/user"));
if (!parsed.ok) {
return { status: "error", message: "Invalid user response" };
}
return { status: "success", data: parsed.data };
The exact schema library is less important than the pattern.
Normalize API Data
API shapes are not always UI shapes.
type ApiUser = {
user_id: string;
display_name: string;
permissions: string[];
};
type UserViewModel = {
id: string;
name: string;
canEdit: boolean;
};
Normalize once.
function toUserViewModel(user: ApiUser): UserViewModel {
return {
id: user.user_id,
name: user.display_name,
canEdit: user.permissions.includes("edit"),
};
}
This prevents API details from leaking into every component.
Error Contracts
Error responses need types too.
type ApiError =
| { code: "UNAUTHORIZED"; message: string }
| { code: "VALIDATION_ERROR"; fields: Record<string, string> }
| { code: "RATE_LIMITED"; retryAfterSeconds: number };
The UI can branch clearly.
function getErrorMessage(error: ApiError) {
switch (error.code) {
case "UNAUTHORIZED":
return "Please sign in again.";
case "VALIDATION_ERROR":
return "Please check the highlighted fields.";
case "RATE_LIMITED":
return `Try again in ${error.retryAfterSeconds}s.`;
}
}
Versioning
APIs evolve.
Versioning strategies include:
additive response changes
deprecated fields with timeline
versioned endpoints
schema compatibility checks
consumer-driven contracts
feature flags around new fields
Frontend code should tolerate additive fields.
It should not silently tolerate removed required fields.
Partial Failure
Not every API response is all or nothing.
type DashboardResponse =
| { status: "success"; data: DashboardData }
| { status: "partial"; data: Partial<DashboardData>; warnings: string[] }
| { status: "error"; message: string };
Modeling partial states explicitly keeps UX honest.
Common Mistakes
Common mistakes include:
casting JSON directly to domain types
duplicating backend contracts manually forever
letting API snake_case leak through UI components
ignoring error response shapes
forgetting versioning and backwards compatibility
validating everything repeatedly instead of at boundaries
The worst pattern is a fake type-safe network layer.
Senior Trade-Offs
Validation has cost.
For low-risk internal endpoints, generated types plus integration tests may be enough.
For payments, auth, permissions, content rendering, or user data, runtime validation is worth it.
I would validate at boundaries, normalize once, and keep components using stable UI models.
Interview Framing
In an interview, say:
I treat API data as unknown until the contract is generated or validated. Then I normalize it into UI models so components do not depend on backend transport shape.
Revision Notes
- Network data is not TypeScript-safe by default.
- Type assertions do not validate JSON.
- Generated contracts reduce drift.
- Runtime schemas validate important boundaries.
- Normalize API data into UI models.
- Error responses need explicit contracts.
Follow-Up Questions
- Why should fetch responses be treated as unknown?
- When would you generate API types?
- What is the difference between API type and UI model?
- How would you type API errors?
- When is runtime validation worth the cost?
How I Would Answer This In A Real Interview
I would explain that the frontend cannot blindly trust network data just because the app uses TypeScript. I prefer generated contracts from OpenAPI, GraphQL, RPC, or shared schemas. At important boundaries, I validate runtime data and normalize it into UI models. That gives components stable types while keeping the network layer honest about errors, versioning, and partial failure.