Back to Blog

Type-Safe Full Stack: Why TypeScript Alone Did Not Save Us

Type-Safe Full Stack: Why TypeScript Alone Did Not Save Us cover image

A production bug I will not forget: an endpoint returned total as a string for one code path and a number for every other. The TypeScript types said number. TypeScript was perfectly happy. The frontend did total + shipping and a customer saw a shipping cost of "4995".

The types were not wrong so much as irrelevant. They described what we hoped the API returned. Nothing had ever checked.

That gap — between types that exist at compile time and data that arrives at runtime — is where most "but we use TypeScript" bugs live. Closing it is what people mean by a type-safe stack, and it is less about clever generics than about deciding where truth enters your system.

TypeScript Types Do Not Exist at Runtime

The thing every TypeScript developer knows and half of them design around anyway.

Types are erased at compile time. An interface is a note to the compiler and nothing else. So the moment data crosses a boundary you do not control — an HTTP request, a JSON response, a database row, an environment variable, a webhook — your types are a claim, not a fact.

// A lie with good intentions.
const res = await fetch("/api/order/1");
const order = (await res.json()) as Order;   // `as` asserts; it never checks

as is not validation. It is you telling the compiler to stop asking questions. Every as on external data is a place where runtime reality can diverge from the type system silently, which is exactly what happened with our string total.

One Schema, Both Jobs

The fix is to define the shape once, in something that exists at runtime, and derive the static type from it. Zod is the common choice and it is the piece I would add first to any codebase.

import { z } from "zod";

export const OrderSchema = z.object({
  id: z.string().uuid(),
  total: z.number().nonnegative(),      // a string here now fails loudly
  currency: z.enum(["GBP", "USD", "EUR"]),
  placedAt: z.coerce.date(),            // parses the ISO string properly
  items: z.array(z.object({
    sku: z.string(),
    qty: z.number().int().positive(),
  })).min(1),
});

export type Order = z.infer<typeof OrderSchema>;   // the type, for free

Now the schema is the single definition. Validate with it at the edge, and the static type follows automatically. When a field changes, one edit updates both, and every consumer fails to compile until it is handled.

Two details worth copying. z.coerce.date() turns the ISO string an API actually sends into a real Date, which removes a whole family of "why is this a string" bugs. And constraints like .min(1) and .positive() encode business rules the type system cannot express — an empty order is now impossible to construct, not just discouraged.

Validate at the Edges, Trust the Middle

The rule that keeps this from becoming exhausting: parse once, at every boundary, and treat everything inside as trustworthy.

The boundaries are: incoming HTTP requests, outgoing API responses you consume, webhook payloads, queue messages, environment variables at startup, and anything read from disk or a cache.

// Inbound: never trust req.body.
app.post("/orders", async (req, res) => {
  const parsed = CreateOrderSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({
      error: { code: "validation_failed", issues: parsed.error.issues },
    });
  }
  const order = await createOrder(parsed.data);   // fully typed from here down
  res.status(201).json(order);
});

// Outbound: validate what a third party sends you, too.
const raw = await fetch(vendorUrl).then((r) => r.json());
const invoice = VendorInvoiceSchema.parse(raw);   // throws if they changed it

That second one catches vendor drift. When a provider quietly changes a field type, you get a clear error at the boundary instead of a corrupted value flowing three services deep.

Validate environment variables at startup. Cheap, and it converts "undefined is not a function at 3am" into "MISSING: STRIPE_SECRET_KEY" before the process accepts traffic. I have never regretted adding it.

Sharing the Schema Across the Stack

This is where the payoff compounds. Put the schemas in a shared package and everything imports the same definition — the API validates with it, the web app builds forms from it, the mobile app parses responses with it.

Change a rule and the compiler tells you every place that has not caught up. That is genuinely the most valuable property of the whole approach: coordination across a monorepo becomes a build failure instead of a bug report.

For a TypeScript client talking to a TypeScript server, tRPC takes this further — you call your backend like a local function with full inference and no code generation. It is excellent, with one condition: it only works when both ends are TypeScript and you control both. For a public API or non-TS consumers, stay with HTTP and publish an OpenAPI document generated from the same schemas.

Where People Overdo It

Being honest about the failure modes, because this can go too far.

Type gymnastics. Conditional types nested four deep, template literal types building strings at compile time. It compiles, it is clever, and nobody on your team can modify it. If a type takes longer to understand than the function it describes, simplify it.

Validating internal calls. Parsing the same object again as it passes between two of your own functions is cost with no benefit. Parse at the edge, then trust.

Branded types everywhere. Useful for genuinely confusable primitives — a UserId that must not be passed where an OrderId goes. Applied to every string, it is friction.

any as a pressure valve. Every any is a hole through which untyped data flows into typed code. unknown plus a parse is the honest version. Turn on strict and noUncheckedIndexedAccess and mean it.

What I Would Add First

If you are retrofitting an existing codebase, do it in this order and stop when the return drops off.

Validate environment variables at startup — one file, immediate value. Then validate every inbound request body at the API boundary. Then validate third-party responses you consume. Then move the schemas into a shared package so the clients use them too.

That sequence would have caught our string-versus-number bug at step two, in the response validation, with a clear error naming the field — instead of on a customer's checkout screen.

Type safety is not about the type system being sophisticated. It is about knowing exactly where data enters your system, and checking it there.

Related Posts