Home/Learn/JavaScript & TypeScript/Typing the API Boundary

Typing the API Boundary

Advanced
TypeScript

The types at the network edge are a claim, not a fact. Validating there — and generating types from the backend rather than hand-writing them — is what keeps a full-stack app honest.

Overview

Every bug that survives a green typecheck tends to live at a boundary: an API returned a shape you did not expect, a query param arrived as a string, a nullable column came back null. TypeScript cannot help here on its own because it never sees the runtime value. Two practices fix it. Validate untrusted input with a schema library so the type and the check come from one definition. And generate the client types from the server's OpenAPI schema so the two cannot drift — a hand-maintained copy of your backend's types is a copy that will be wrong.

Validate, Do Not Assert

One schema, giving both a runtime check and a static type.

Zod: one definition, both a check and a type
import { z } from 'zod'

const ProblemSchema = z.object({
  slug: z.string(),
  title: z.string(),
  difficulty: z.enum(['easy', 'medium', 'hard']),
  tags: z.array(z.string()),
  hints: z.array(z.string()).nullable(),
})
type Problem = z.infer<typeof ProblemSchema>    // the type, derived

export async function getProblem(slug: string): Promise<Problem> {
  const res = await fetch(`/api/problems/${slug}`)
  if (!res.ok) throw new ApiError(res.status, await res.text())
  return ProblemSchema.parse(await res.json())  // throws on mismatch
}

// Compare: 'as Problem' claims the shape and fails 200 lines later
// with "cannot read property of undefined", far from the real cause.

// safeParse when a failure is expected and handled
const result = ProblemSchema.safeParse(input)
if (!result.success) return { error: result.error.flatten() }

Generated Clients

FastAPI publishes OpenAPI. Generate from it, commit the output, and regenerate in CI.

Generate from OpenAPI; check drift in CI
# FastAPI serves the schema at /openapi.json for free
npx openapi-typescript http://localhost:8000/openapi.json -o src/generated/api.ts

// Then the client types come from the server, not from memory
import type { paths } from '@/generated/api'
type Problem = paths['/problems/{slug}']['get']['responses']['200']['content']['application/json']

// The failure mode this prevents: the backend renames a field,
// the frontend keeps compiling against a stale hand-written type,
// and the bug appears in production as an undefined.

// Make it a CI step, not a habit:
#   npm run generate:api && git diff --exit-code src/generated/
// A red build is how you find out the contract moved.

Errors and Nullability

Typed errors and honest optionality at the edge.

ApiError, Result, and wire types
// fetch does not reject on 4xx/5xx — model the failure explicitly
class ApiError extends Error {
  constructor(readonly status: number, readonly body: unknown) {
    super(`API ${status}`)
    this.name = 'ApiError'
  }
}

// Or return a Result instead of throwing, when the caller must handle it
type Result<T> = { ok: true; data: T } | { ok: false; error: ApiError }

// Be honest about nullability. A nullable column is T | null,
// and pretending otherwise moves the crash, it does not remove it.
interface Attempt {
  score: number | null        // not yet graded
  submittedAt: string         // ISO string over the wire, NOT a Date
}
// Dates do not survive JSON. Type the wire shape as string and
// convert at the boundary, or every consumer will guess differently.

Key Points to Remember

  • 1`as` at the network boundary is an unverified claim — validate with a schema so the type and the check share one definition
  • 2z.infer derives the static type from the runtime schema, so they can never disagree
  • 3Generate client types from the server OpenAPI schema and check for drift in CI rather than hand-maintaining them
  • 4fetch does not reject on 4xx/5xx — model API failures with a typed error or a Result union
  • 5Dates arrive as ISO strings over JSON; type the wire shape honestly and convert once at the boundary

Interview Questions

Sign in to ask Aria
1

Why is `await res.json() as User` unsafe, and what should you do instead?

Medium
2

How do you stop frontend types drifting from the backend contract?

Hard
3

How should a Date field be typed on an API response type?

Medium

Ask Aria about Typing the API Boundary

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…