The TypeScript trap that hits everyone eventually: JSON.parse(req.body) as User compiles cleanly. It also lies. The compiler never checked the runtime shape — it just took your word for it.

Compile-time types and runtime validation solve different problems. You need both. The good news is that modern tooling makes them feel like one.

Compile-Time Types Stop At Runtime

TypeScript types exist only during type-checking. After compilation, they're erased — the JavaScript that ships has no idea what a User is.

TypeScript
type User = { id: string; email: string; isAdmin: boolean };

async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return res.json() as User; // 🐛 lying to the compiler
}

const user = await getUser('42');
if (user.isAdmin) deleteEverything(); // boom — what if isAdmin came back undefined?

The as User assertion is identical in safety to as any. The compiler stops checking; the runtime trusts whatever the network returned. Production data laughs.

This isn't TypeScript's fault — type erasure is a deliberate design decision that keeps runtime cost zero. It does mean you have a job: bridge the gap at the network boundary.

Validation Belongs At Boundaries

Don't validate everywhere — that's overhead with no payoff. Validate at boundaries where untrusted data enters your code:

  1. HTTP request bodies (your API receives them)
  2. HTTP response bodies (you fetch from external APIs)
  3. localStorage, sessionStorage (user could have edited it)
  4. URL parameters (anyone can craft one)
  5. Message events (postMessage, BroadcastChannel)
  6. Worker messages (different contexts)
  7. JSON in databases when stored as text

Inside your app, between your own functions, types are enough. The compiler tracked the data from creation. At the boundary, the compiler couldn't know — so the runtime has to.

Schemas Can Produce Types

The breakthrough that makes validation feel free: schemas that generate the TypeScript type from the runtime check.

TypeScript src/schemas/user.ts
import { z } from 'zod';

export const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  isAdmin: z.boolean(),
});

export type User = z.infer<typeof UserSchema>;

One definition. Two outputs: the runtime parser and the TypeScript type. Change the schema → the type updates → all call sites update with it. There's nothing for the validation and the type to disagree about because they came from the same source.

Use this for the network boundary:

TypeScript
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return UserSchema.parse(await res.json()); // throws if shape is wrong
}

UserSchema.parse() returns User. The compiler is happy. The runtime is safe. The two are kept in sync by the schema itself.

The library matters less than the pattern. Zod, valibot, arktype, ajv, sury — all do roughly the same thing with different ergonomics and runtime sizes. Pick one and stick with it.

Schema-as-source-of-truth pipeline. A single Zod schema declaration on top splits into two outputs: a TypeScript type for compile-time autocomplete and a runtime parser that throws on bad data — the two cannot drift apart.
Edit the schema once. The TS type and runtime check both update.

Do Not Validate Everything Everywhere

A common over-correction: parsing every object on every function call. The cost is real, the benefit is zero.

TypeScript
// 🐛 over-validation — internal data is already typed
function processOrder(order: Order) {
  const validated = OrderSchema.parse(order); // wasted CPU
  // ...
}

If order was constructed inside your app with proper types, you don't need to re-validate it. The compiler already proved it matches. Re-parsing adds latency and noise without finding bugs.

The rule: validate where data enters from outside your control. Inside the app, trust the types.

Brand Types Lock Validated Data

A subtle but powerful pattern: once data is validated, mark it with a brand type so the compiler knows it's been checked:

TypeScript
type Branded<T, B> = T & { readonly __brand: B };
type Email = Branded<string, 'Email'>;

const EmailSchema = z.string().email().transform((e) => e as Email);

function sendEmail(to: Email, body: string) { /* ... */ }

sendEmail('bad-input', 'hi'); // ❌ string is not Email
const validated = EmailSchema.parse('a@b.com');
sendEmail(validated, 'hi');   // ✅

This makes "I haven't validated this yet" and "I have" two different types. You can't accidentally pass raw input to functions that expect verified input — the compiler stops you.

Pro Tips

  1. Schema first, type inferred. z.infer<typeof X> keeps them in sync.
  2. Validate at every boundary. Network, storage, URL, worker messages.
  3. Trust internal types. Don't re-validate data your code constructed.
  4. Use brand types for verified inputs. Email, UserId, SafeHtml — once parsed, always typed.
  5. Pick one validation library and standardize. Drift between zod and ajv in one app is messy.

Final Tips

The cleanest TypeScript apps I've worked in treat schemas as the source of truth for any data crossing a boundary. Types are what the compiler sees; schemas are what the runtime enforces; both come from one definition.

It's a small habit that prevents the entire class of "the API said one thing and TypeScript believed it" bugs.

Good luck — and may your runtime never disagree with your types 👊