End-to-End Type Safety

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

End-to-end type safety means your client code gets its request and response types from the server contract instead of rewriting them by hand. It is powerful, but it is still compile-time safety: the deployed network boundary can drift, so runtime validation and versioning still matter.

Framework and tool examples are shown as read-only reference code. The live playground uses pure TypeScript to show the shape and the failure mode without running a server or client.

One contract, two sides

A contract is the shared description of an API's inputs and outputs. In Section 23.2, the contract lived at the route boundary: validate the request, return a typed envelope. End-to-end type safety carries that contract into the client so it can call the route with the right input shape and handle the real response shape.

The concrete payoff is boring in the best way. If the server says a launch needs karmaRequired, the client does not invent karma, points, or requiredKarma in a second file.

type CreateLaunchInput = {
  name: string;
  karmaRequired: number;
};
 
type Launch = {
  id: string;
  name: string;
  karmaRequired: number;
};
 
type ApiEnvelope<T> =
  | { ok: true; data: T }
  | { ok: false; error: { code: "invalid_request" | "plan_required"; message: string } };
 
function createLaunch(input: CreateLaunchInput): ApiEnvelope<Launch> {
  if (input.karmaRequired < 0) {
    return {
      ok: false,
      error: { code: "invalid_request", message: "karmaRequired must be positive" },
    };
  }
 
  return {
    ok: true,
    data: {
      id: "launch_search",
      name: input.name,
      karmaRequired: input.karmaRequired,
    },
  };
}

That example is one file only, but real projects split it across packages. The pattern is the same: the client imports or generates the contract, then TypeScript checks calls against the same set of allowed values the server uses.

tRPC: server router as the source

RPC means remote procedure call, a style where client code calls named server operations while the framework handles the HTTP request. tRPC is a TypeScript RPC framework whose server router, the object that groups named procedures, becomes the source the client reads from. Current tRPC docs show helper types such as inferRouterInputs and inferRouterOutputs for deriving client-side input and output types from AppRouter.

Read-only reference code:

import { initTRPC } from "@trpc/server";
import * as z from "zod";
 
const t = initTRPC.create();
 
export const appRouter = t.router({
  launch: t.router({
    create: t.procedure
      .input(
        z.object({
          name: z.string().min(1),
          karmaRequired: z.int().min(0),
        }),
      )
      .mutation(({ input }) => {
        return {
          id: "launch_search",
          name: input.name,
          karmaRequired: input.karmaRequired,
        };
      }),
  }),
});
 
export type AppRouter = typeof appRouter;

Read-only reference code:

import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "../server/router.ts";
 
type RouterInputs = inferRouterInputs<AppRouter>;
type RouterOutputs = inferRouterOutputs<AppRouter>;
 
export type CreateLaunchInput = RouterInputs["launch"]["create"];
export type CreateLaunchOutput = RouterOutputs["launch"]["create"];
 
const draft: CreateLaunchInput = {
  name: "Search launch",
  karmaRequired: 10,
};
 
function renderLaunch(launch: CreateLaunchOutput): string {
  return launch.name + " needs " + launch.karmaRequired + " karma";
}

That is where the autocomplete feeling comes from. The client does not hand-write route names and shapes; it follows the router type. If the procedure input changes, the client type changes with it during the next type check.

Keep the claim precise. tRPC gives you a strong TypeScript connection when client and server are built from compatible code. It does not make a network response self-validating, and it does not make two already-deployed versions agree after one side changes.

OpenAPI: contract-first for HTTP APIs

OpenAPI is a language-agnostic description format for HTTP APIs, usually written as JSON or YAML. Code generation means a tool reads that description and writes client types, client functions, server stubs, tests, or docs from it.

That makes OpenAPI the contract-first alternative, which means the API description is the blueprint that clients and servers build around. Instead of sharing a TypeScript router type, the team publishes an API description that many languages can read. TypeScript clients can generate types from it, but so can Python, Java, Go, or mobile clients.

{
  "openapi": "3.1.0",
  "info": {
    "title": "Launch API",
    "version": "1.0.0"
  },
  "paths": {
    "/launches": {
      "post": {
        "operationId": "createLaunch",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateLaunchInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Launch created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Launch"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "CreateLaunchInput": {
        "type": "object",
        "required": ["name", "karmaRequired"],
        "properties": {
          "name": { "type": "string" },
          "karmaRequired": { "type": "integer", "minimum": 0 }
        }
      },
      "Launch": {
        "type": "object",
        "required": ["id", "name", "karmaRequired"],
        "properties": {
          "id": { "type": "string" },
          "name": { "type": "string" },
          "karmaRequired": { "type": "integer" }
        }
      }
    }
  }
}

OpenAPI is a blueprint. It is not the building. You still need the server to implement the described behavior, generated clients to stay current, and boundary validation where values enter the running program.

Where end-to-end types break

Deploy skew is the gap where one deployed version talks to a different deployed version. A wire shape is the JSON shape actually sent over the network. Maybe the server now sends karma, but a user still has an old client that expects karmaRequired. Both versions may have passed type checking in their own build.

This playground shows the shape. The first response uses the shared contract. The second response simulates a server that changed its wire shape while an old client parser still accepts too much.

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

→ Search launch needs 10 karma
→ Search launch needs undefined karma

Good teams make this boring: avoid breaking wire shapes without a version plan, add optional fields before requiring them, keep old fields through a transition when needed, validate responses at trust boundaries, and run contract tests in CI.

Next lesson moves from API contracts to data storage: how ORMs and query builders infer types from database schemas, and where those types can drift from the real database.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion