Volume 4

Q213: Typing API Contracts and Runtime Schemas

Difficulty: SeniorFrequency: HighAnswer time: 8-12 minutes

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.