loke.dev
TypeScript Architecture Zod Software Engineering

TypeScript Architecture: Enforcing Type Safety at Boundaries

Master TypeScript architecture by separating compile-time logic from runtime defense. Learn to use Zod as your trust boundary and stop relying on any-casts.

Published 4 min read

Three months ago, I spent four hours chasing a TypeError: Cannot read property 'map' of undefined in a production dashboard. The PR sailed through CI with a perfect green checkmark. Why? The original author annotated the API response as User[], despite the backend occasionally coughing up a paginated object wrapper { data: User[], total: number }.

TypeScript didn’t save us because the type was a lie. We were "trusting the contract," which is the fastest way to spend your night debugging at 2:00 AM. The compiler isn't a defensive shield; it’s just a linting tool for your assumptions. If your runtime data doesn't match your static types, your code is a collection of expensive, incorrect guesses.

The Myth of Runtime Type Safety

Stop writing interface User { id: string } and pretending the universe cares. TypeScript evaporates at runtime. When a JSON payload hits your fetch call, the compiler doesn't check if the server sent you a string instead of an object.

Most teams treat TypeScript as "documentation that happens to compile." They define a type, then cast the incoming response using as User.

// The "I'm feeling lucky" approach (Don't do this)
interface User { id: string; name: string; }

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return (await res.json()) as User; // A total lie.
}

This is dangerous. Schema changes aren't "if"; they’re "when." You aren't notified when things break. You’re just setting a landmine for your future self. Build a trust boundary.

Building a Trust Boundary with Zod

You use Zod because it’s the only way to reconcile runtime reality with static types. By defining a schema, you create a single source of truth. The z.infer utility is mandatory.

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  roles: z.array(z.enum(['admin', 'user'])),
});

type User = z.infer<typeof UserSchema>;

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data = await res.json();
  
  // The boundary: if this fails, the app stops here.
  return UserSchema.parse(data);
}

Enforce this at the boundary to stop any from leaking into your business logic. If the API returns garbage, UserSchema.parse() throws a useful error. No more guessing why a property is missing. The stack trace points right at the violation. It’s almost as if errors should be informative.

Beyond Any: TypeScript Generics

I see developers reach for any the moment a function needs to handle a slightly different shape. That’s a failure of imagination. Use generic constraints to keep things safe.

Don't just use <T>. Constrain it.

// The "Any-as-Escape-Hatch" habit
function processItems(items: any[]) { /* ... */ }

// The Professional approach
function processItems<T extends { id: string | number }>(items: T[]) {
  return items.map(item => ({ ...item, processedAt: Date.now() }));
}

Constraining T forces the consumer to provide an object with an id. You get your autocomplete, you get refactoring support, and you kill the urge to cast values.

A common pitfall is over-engineering generics into "type-level programming." If your generic signature looks like T extends Record<K, V> ? U : never, stop. You’re doing too much. Complex conditional types are great for library authors, but in application code, they just produce inscrutable error messages for your team.

Mastering Type Narrowing

Type narrowing is how you build a reliable TypeScript architecture. Avoid if (data.user) if user could be a falsy object or an empty string. Use explicit type guards.

type APIResponse = 
  | { status: 'success'; data: User }
  | { status: 'error'; message: string };

function handleResponse(res: APIResponse) {
  if (res.status === 'error') {
    console.error(res.message);
    return;
  }
  
  // Here, TypeScript knows it's the success case
  console.log(res.data.email);
}

This is a discriminated union. It’s the most powerful pattern for state management. If you have a status field, use it to narrow your types. This is infinitely safer than checking for property existence with in or hasOwnProperty, which usually fails to narrow correctly in complex shapes anyway.

Preparing for the TypeScript 7.0 Shift

TypeScript 6.0 is the final release based on the legacy compiler. We are in a transition period. If you’re preparing for the Go-based compiler in 7.0, start by enabling --stableTypeOrdering. Yes, it hits type-checking performance by up to 25%. It forces you to clean up non-deterministic type definitions that will break under the new engine.

Stop using interface for everything. type is better for complex shapes, union types, and mapped types. Reserve interface for when you specifically need declaration merging. If you don't know why you'd need declaration merging, you don't need interface. Use type.

The "Branded Type" Gotcha

If you are passing around IDs, stop using string. You will eventually pass a userId where an orderId is expected. Use branded types to create nominal safety:

type UserId = string & { __brand: 'UserId' };

function getUser(id: UserId) { /* ... */ }

const rawId = "123";
getUser(rawId); // Error: Argument of type 'string' is not assignable to 'UserId'

It’s just a label at compile time, but it prevents you from passing the wrong string into the wrong function. It costs nothing at runtime.

Your architecture is only as strong as your weakest type cast. Stop silencing the compiler; start using it to enforce your domain constraints. If you find yourself writing // @ts-ignore, go back to the schema. Fix the source. Everything else is just delaying the inevitable bug.

Resources