Building Typed APIs

Expert · 20 min read · ▶ live playground · ✦ checkpoint

Typed APIs are about one promise: a route should not treat outside data as trusted until a validator has checked it. In a TypeScript server, the good shape is request data in, validated receipt out, then a response envelope whose success and failure cases are both typed.

Framework snippets are shown as read-only reference code for real projects; the live playground uses pure TypeScript to model the same route core without running a server.

Start with the route boundary

An API is a set of HTTP entry points other code can call, HTTP is the request/response protocol web clients and servers speak, and a route is one method plus path, such as POST /launches. The dangerous moment is the first line of the handler, the function that runs for a matched route. The request body came from outside your program, so an annotation there would be only a promise. It would be the same costume problem from Section 13.

A schema is a runtime description of allowed input, and a validator is code that checks unknown input against that schema and returns validated data or a failure. That validated data is the receipt for a check that ran.

Read-only reference code:

import { Hono } from "hono";
import { createMiddleware } from "hono/factory";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
 
type Plan = "free" | "pro";
 
type AuthUser = {
  id: string;
  name: string;
  plan: Plan;
};
 
type Variables = {
  user: AuthUser;
};
 
const CreateLaunch = z.object({
  name: z.string().min(1),
  karmaRequired: z.number().int().min(0),
});
 
const requireUser = createMiddleware<{ Variables: Variables }>(async (c, next) => {
  c.set("user", {
    id: "user_mina",
    name: "Mina",
    plan: "pro",
  });
  await next();
});
 
const app = new Hono<{ Variables: Variables }>();
 
app.post("/launches", requireUser, zValidator("json", CreateLaunch), (c) => {
  const user = c.var.user;
  const body = c.req.valid("json");
 
  return c.json(
    {
      ok: true,
      data: {
        id: "launch_search",
        ownerId: user.id,
        name: body.name,
        karmaRequired: body.karmaRequired,
      },
    },
    201,
  );
});

Hono is not magic in that snippet. The route pairs a validator with the handler, then the handler reads c.req.valid("json") instead of pretending c.req was already safe. The type of body comes from the validator path. No assertion is needed in the route body.

Fastify has a nearby shape when you use a type provider. A type provider is a Fastify TypeScript helper that lets the route handler infer request types from the route schema instead of writing separate generic route types.

Read-only reference code:

import Fastify from "fastify";
import { JsonSchemaToTsProvider } from "@fastify/type-provider-json-schema-to-ts";
 
const server = Fastify().withTypeProvider<JsonSchemaToTsProvider>();
 
server.post(
  "/launches",
  {
    schema: {
      body: {
        type: "object",
        required: ["name", "karmaRequired"],
        properties: {
          name: { type: "string", minLength: 1 },
          karmaRequired: { type: "number", minimum: 0 },
        },
      },
    } as const,
  },
  async (request, reply) => {
    const body = request.body;
 
    return reply.code(201).send({
      ok: true,
      data: {
        id: "launch_search",
        name: body.name,
        karmaRequired: body.karmaRequired,
      },
    });
  },
);

The framework changes, but the contract stays the same: keep the schema close to the route, let the handler receive the parsed shape, and avoid dressing raw input in a type it has not earned.

Make the API core boring

The pure TypeScript core below models what your handler should do after parsing. A response envelope is a shared outer JSON wrapper, like the envelope around letters with different contents: clients can always check ok, then read either data or error. A discriminated union is an object union where a literal field, here ok and code, tells the checker which member you have.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ 201: launch created
→ 403: plan_required - Upgrade to Pro before launching.
→ 400: invalid_request - name is required

The break-it line fails for a good reason. success is a value returned by a route core, so its type is still the whole response union. A client must check body.ok before reading data. That is not TypeScript being stubborn; it is the same set logic from lesson 1.1.

Pass context through middleware, not globals

Middleware is code that runs before or around a route handler, and context is the per-request storage the framework passes along that route chain. Auth, request ids, tenant ids, and database handles often enter here.

The rule is the same as request bodies: set context once, type it once, then make downstream handlers read the typed value. Do not make every handler re-parse the same header or reach for a global "current user" variable. A request is one trip through the building; context is the badge clipped to that one visitor, not a note taped to the front door.

Read-only reference code:

import { createMiddleware } from "hono/factory";
 
type Plan = "free" | "pro";
 
type AuthUser = {
  id: string;
  name: string;
  plan: Plan;
};
 
type AuthVariables = {
  user: AuthUser;
};
 
export const requireUser = createMiddleware<{ Variables: AuthVariables }>(async (c, next) => {
  c.set("user", {
    id: "user_mina",
    name: "Mina",
    plan: "pro",
  });
 
  await next();
});

Typed context is useful, but it is not a permission system by itself. The middleware must actually run on the routes that read the value. Types describe the handler contract; routing decides which middleware is in front of which handler.

Return errors as data when callers can recover

Expected API failures should be part of the response type. Invalid request, missing permission, not found, and plan-required are not surprises. They are cases clients can render.

That is where Part III pays off. Your error envelope can be a discriminated union:

type ApiError =
  | { code: "invalid_request"; message: string; field: string }
  | { code: "not_found"; message: string; resource: "launch" | "user" }
  | { code: "plan_required"; message: string; requiredPlan: "pro" };
 
type ApiEnvelope<T> =
  | { ok: true; data: T }
  | { ok: false; error: ApiError };

Clients now get one predictable first question: if (body.ok). Inside the failure branch, error.code narrows the exact error shape. You do not need assertions, and you do not need every caller to guess whether a failed response returns { message }, { error }, { errors }, or plain text.

Use thrown errors for broken invariants, failed dependencies, or truly unexpected problems. Use envelopes for expected outcomes your caller can handle. A typed API is not one that never fails. It is one that names its failures clearly enough for both the checker and the next developer.

Next lesson follows that boundary across the client/server line: shared types, tRPC-style inference, OpenAPI contracts, and where end-to-end types stop being enough.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion