Networking#

TypeScript inherits the JavaScript networking story; the runtime is what matters (Node, Deno, Bun, browsers). See Networking for the runtime-level APIs.

This page focuses on TypeScript-specific patterns: typed clients, end-to-end type safety, and validated request and response schemas.

fetch with Types#

The simplest typed wrapper.

interface User {
  id: number;
  name: string;
  email: string;
}

async function getUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()) as User;
}

The as User is a type assertion, not a check. The runtime will hand you whatever the server actually sent, regardless of the type you wrote. Validate at the boundary.

import { z } from "zod";

const User = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});
type User = z.infer<typeof User>;

async function getUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return User.parse(await res.json());
}

End-to-End Type Safety#

TypeScript’s defining networking pattern: the client knows the API’s types without code generation.

  • tRPC, procedure types flow from server to client through TS imports.

  • Hono RPC, typed routes consumable by the client.

  • ts-rest, contract-first REST.

  • oRPC, OpenAPI-aware RPC with full TS types.

// server.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";

const t = initTRPC.create();
export const appRouter = t.router({
  getUser: t.procedure
    .input(z.object({ id: z.number() }))
    .query(({ input }) => db.user.findFirst({ where: { id: input.id } })),
});
export type AppRouter = typeof appRouter;

// client.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "./server";

const trpc = createTRPCClient<AppRouter>({
  links: [httpBatchLink({ url: "/api/trpc" })],
});

const user = await trpc.getUser.query({ id: 1 });   // fully typed

OpenAPI-Generated Clients#

When the API is REST and the contract lives in OpenAPI, generate a typed client.

GraphQL#

The pattern that has stuck: write queries as .graphql files (or tagged templates), run codegen, get fully-typed hooks / functions.

WebSockets with Types#

The browser WebSocket API is dynamically typed. To keep messages typed, encode them as discriminated unions and validate on receipt.

import { z } from "zod";

const Message = z.discriminatedUnion("type", [
  z.object({ type: z.literal("ping"), at: z.number() }),
  z.object({ type: z.literal("chat"), from: z.string(), text: z.string() }),
]);
type Message = z.infer<typeof Message>;

ws.addEventListener("message", (e) => {
  const parsed = Message.safeParse(JSON.parse(e.data));
  if (!parsed.success) return;
  handle(parsed.data);
});

gRPC#

Server Frameworks#

The TS-first backend frameworks all expose their request and response types.

  • Hono, typed routes; runs anywhere.

  • Fastify, type providers (@sinclair/typebox or Zod) for full request/response typing.

  • NestJS, decorator + DI; types via class metadata.

import { Hono } from "hono";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";

const app = new Hono();
app.post(
  "/users",
  zValidator("json", z.object({ name: z.string() })),
  (c) => {
    const { name } = c.req.valid("json");   // typed
    return c.json({ id: 1, name });
  },
);

Pitfalls#

  • ``as`` is not a check, assertions don’t validate. Parse at boundaries.

  • ``any`` from JSON, await res.json() is any; pin to unknown and validate.

  • ``fetch`` doesn’t throw on ``!res.ok``, check yourself.

  • Different runtimes, different APIs, Node.fetch and browser fetch are similar but not identical (Node lacks credentials cookies, etc.). Test in the runtime you’ll deploy to.

  • Type-only imports, when importing types across server/client, use import type { ... } so bundlers can elide them.