← Back to list

TypeScript Runtime Validation at the Edge: tRPC, Zod, and Durable Clients

How to keep your types honest when code runs in edge functions, offline tabs, and flaky networks.

Neurobyte · 2025-11-29 19:32 · 20 claps · 5.6 min read paywalled
#typescript #trpc #zod #edge-computing #api
Open on Medium ↗
Wiki topics: 🌐 · Web Development

TypeScript Runtime Validation at the Edge: tRPC, Zod, and Durable Clients

How to keep your types honest when code runs in edge functions, offline tabs, and flaky networks.

Learn how to combine TypeScript, tRPC, and Zod to get real runtime validation at the edge — with durable clients that stay correct even when the network doesn’t.

There’s a quiet lie a lot of TypeScript teams tell themselves:

“If it compiles, we’re safe.”

In a monolithic Node server, that illusion mostly holds. Your types and your runtime live close together. Your API and your caller typically share a codebase.

But once you move to edge runtimes, serverless functions, and durable clients that cache, retry, and sync over flaky networks, the story changes. The distance between “what TypeScript believes” and “what actually hit the wire” gets wider — sometimes painfully so.

This is where tRPC + Zod + TypeScript become more than a nice DX stack. They become a survival kit for runtime validation at the edge.

Let’s unpack what that actually looks like.

Why TypeScript Alone Isn’t Enough at the Edge

TypeScript’s type system is compile-time only. Once your code is bundled and shipped:

  • Edge functions receive raw JSON from arbitrary clients.
  • Browsers hydrate from stale IndexedDB or localStorage.
  • Mobile apps send payloads shaped by cached code, not the latest schema.

Your compiler can’t save you from any of this.

And yet, our edge architectures are increasingly complex:

  • Multiple edge locations that may run slightly different versions.
  • Durable clients that queue mutations while offline.
  • Background sync flows that replay requests long after a deployment.

If you don’t have runtime validation of both requests and responses, you’re basically hoping everything stays in lockstep forever. Spoiler: it won’t.

The Core Idea: One Schema, Three Uses

The tRPC + Zod approach is simple but powerful:

  1. Declare your schema in Zod (inputs, outputs, errors).
  2. tRPC uses that schema to validate at runtime.
  3. TypeScript infers types from the Zod schemas so your editor and compiler stay in sync.

One definition drives:

  • Runtime validation on the edge server.
  • Static typing on the client.
  • Serialization constraints for durable storage (what you can safely cache).

You stop duplicating contracts in OpenAPI, JSON Schemas, and random interfaces and instead maintain a single source of truth in Zod.

Architecture: Edge Router + Durable Client

Think of the system like this:

          ┌───────────────────────────┐
          │     Durable Client        │
          │ (SPA / mobile / desktop)  │
          └───────────┬───────────────┘
                      │
                      │  tRPC calls (HTTP / fetch)
                      v
             ┌──────────────────────┐
             │  Edge Runtime /      │
             │  tRPC Router (Zod)   │
             └──────────┬───────────┘
                        │
                        │  Internal services / DB
                        v
                ┌─────────────────┐
                │   Core APIs     │
                │   DB, queues    │
                └─────────────────┘

Runtime validation happens primarily at the edge:

  • Incoming payloads are validated by Zod before hitting business logic.
  • Responses are validated before sending back to clients (optional but powerful).
  • Durable clients treat server contracts as actual runtime contracts, not mere TypeScript hints.

Step 1: Define Runtime Schemas with Zod

Let’s start with a simple example: a “createTodo” mutation.

// src/schemas/todo.ts
import { z } from "zod";

export const createTodoInput = z.object({
  title: z.string().min(1).max(200),
  description: z.string().max(2_000).optional(),
  dueDate: z.string().datetime().optional(),
});

export const todo = z.object({
  id: z.string().uuid(),
  title: z.string(),
  description: z.string().nullable(),
  dueDate: z.string().datetime().nullable(),
  createdAt: z.string().datetime(),
  completed: z.boolean(),
});

export type CreateTodoInput = z.infer<typeof createTodoInput>;
export type Todo = z.infer<typeof todo>;

A few key points:

  • The Zod schemas define what is allowed at runtime.
  • TypeScript types CreateTodoInput and Todo are inferred. No duplication.
  • Anything stored offline or replayed later should conform to these same shapes.

Step 2: Build a tRPC Router for the Edge

On the edge side (e.g., Vercel Edge Functions, Cloudflare Workers), you register these schemas with tRPC.

// src/server/router.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";
import { createTodoInput, todo } from "../schemas/todo";

const t = initTRPC.context<{}>().create();

export const appRouter = t.router({
  createTodo: t.procedure
    .input(createTodoInput)
    .output(todo)
    .mutation(async ({ input }) => {
      // input has full type safety + runtime validation
      const saved = await saveTodoToDb(input); // returns Todo-ish

      // Optionally, validate before returning
      return todo.parse(saved);
    }),

  listTodos: t.procedure
    .output(z.array(todo))
    .query(async () => {
      const rows = await fetchTodosFromDb();
      return z.array(todo).parse(rows);
    }),
});

export type AppRouter = typeof appRouter;

Here’s what we get:

  • .input(createTodoInput) → All requests go through Zod at runtime.
  • .output(todo) → tRPC knows the output shape; you can also parse on the way out to be stricter.
  • saveTodoToDb and fetchTodosFromDb can be plain async functions—your edge layer enforces the contract.

Step 3: Typed Clients and Durable State

On the client, you can generate a tRPC client that shares the types inferred from AppRouter:

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

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

Now in your React app:

import { trpc } from "./trpc";

async function create() {
  const todo = await trpc.createTodo.mutate({
    title: "Write Medium article",
    // TS will complain if you add invalid fields or wrong types
  });

  // `todo` is fully typed as `Todo`
}

So far so good. But where durable clients really benefit is when you start caching and replaying.

Durable Mutations with Validation

Imagine you use something like TanStack Query or your own queue to store mutations offline:

type QueuedMutation =
  | {
      type: "createTodo";
      input: CreateTodoInput;
    }
  | {
      type: "completeTodo";
      input: { id: string };
    };

When you go back online and replay:

async function replay(queue: QueuedMutation[]) {
  for (const job of queue) {
    if (job.type === "createTodo") {
      // Zod + tRPC validate this again at the edge
      await trpc.createTodo.mutate(job.input);
    }
    // handle other types...
  }
}

Even if the queue has been sitting around for hours or days, the edge layer still enforces:

  • title length constraints.
  • dueDate format.
  • New validators you added in a recent deploy.

Your durable client doesn’t push mystery payloads into your system; it’s still bound by the same Zod schemas.

Edge Concerns: Performance and Error Shapes

You might be wondering: is it okay to run Zod validation on every edge request?

For most apps, yes:

  • Edge runtimes are optimized for short-lived, CPU-light tasks.
  • Zod is plenty fast for typical input sizes (dozens or hundreds of fields, not megabytes of JSON).
  • The cost of accepting bad input is usually much higher than the cost of validating.

Some practical tips:

  • Validate at boundaries, not everywhere. Validate the incoming request object once, then pass typed data downward.
  • Standardize error shapes. Transform Zod errors into a consistent API error format so clients can handle them gracefully.
  • Avoid over-validating huge blobs. For very large payloads (file uploads, big documents), validate metadata and structure rather than every character.

Example error mapping:

import { ZodError } from "zod";
import { TRPCError } from "@trpc/server";

function zodToTrpcError(err: unknown) {
  if (err instanceof ZodError) {
    throw new TRPCError({
      code: "BAD_REQUEST",
      message: "Invalid input",
      cause: err.flatten(),
    });
  }
  throw err;
}

Wrap your resolvers or use tRPC middlewares so this is handled consistently.

Versioning and Schema Evolution with Durable Clients

One last wrinkle: durable clients might be on older versions of the app. Their queued mutations may be shaped according to an old schema.

Some patterns that help:

  • Favor additive changes: make new fields optional, not required.
  • Keep old fields around with fallback behavior; deprecate slowly.
  • If you must break, consider a version field in your inputs and handle v1 vs v2 logic explicitly.

Because everything funnels through Zod, you at least get deterministic failures instead of silent corruption. Clients can see that their old payload is invalid and prompt a refresh, re-auth, or manual resolution.

Wrapping Up

Edge runtimes and durable clients are amazing for latency and resilience. They’re also ruthless about exposing the gap between your TypeScript types and your actual runtime behavior.

By combining:

  • Zod for runtime validation,
  • tRPC for type-safe transport, and
  • TypeScript as the glue tying them together,

you get a setup where:

  • Every edge request is checked against real contracts.
  • Clients enjoy end-to-end type safety.
  • Durable state and offline queues stay within safe, validated bounds.

If you’re already using TypeScript at the edge, the next natural step is to make your types real at runtime. Start with one endpoint, wrap it in Zod and tRPC, and see how much more confident you feel about the code you ship.

And if you’ve already gone down this path — especially with edge runtimes and offline clients — share your patterns and war stories in the comments. A lot of teams are trying to solve this right now, and we don’t all need to learn the same lessons the hard way.


메타데이터
post_id
8d86ee36d305
slug
typescript-runtime-validation-at-the-edge-trpc-zod-and-durable-clients-8d86ee36d305
url
https://medium.com/@kaushalsinh73/typescript-runtime-validation-at-the-edge-trpc-zod-and-durable-clients-8d86ee36d305
canonical_url
https://medium.com/@kaushalsinh73/typescript-runtime-validation-at-the-edge-trpc-zod-and-durable-clients-8d86ee36d305
author_url
https://medium.com/@kaushalsinh73
status
ok
fetched_at
2026-06-21 19:25:17