⚡IntermediateTypeScript in Practice

Type-Safe APIs & Runtime Validation

Bridge the gap between TypeScript compile-time safety and runtime data with typed fetch, schema validation (Zod), and branded types.

Overview

TypeScript's compile-time types disappear at runtime. When you call an external API, read a file, or accept user input, the data coming in is `unknown` - your type annotations are promises, not guarantees. There are three strategies for bridging this gap: **Type assertions** (`as User`) - tell TypeScript to trust you. Fast but dangerous - no validation actually happens at runtime. **Type guards / manual validation** - write runtime checks that narrow the type. Safe but verbose for complex schemas. **Schema validation libraries** (Zod, Valibot, Arktype) - define a schema once, get both runtime validation and TypeScript type inference from the same source. Zod is the most popular: `z.object({ name: z.string(), age: z.number() })` gives you a schema that validates at runtime AND infers the TypeScript type `{ name: string; age: number }` automatically. **Branded types** add phantom labels to primitive types to prevent mixing them up: `UserId` and `PostId` are both strings but are not interchangeable.

Key Points

  • •`as Type` assertions trust the developer - zero runtime checking; use only when you control the data shape
  • •Type guard pattern: `function isUser(x: unknown): x is User { return typeof x === "object" && ... }`
  • •Zod: `const UserSchema = z.object({ name: z.string() })` - single source of truth for type + validation
  • •`z.infer<typeof UserSchema>` extracts the TypeScript type from a Zod schema
  • •`schema.parse(data)` throws if invalid; `schema.safeParse(data)` returns `{ success, data } | { success: false, error }`
  • •Branded types: `type UserId = string & { readonly __brand: "UserId" }` - prevent misuse of string primitives
  • •`fetch + as` is the most common anti-pattern in TypeScript; always validate API responses

Examples

Manual type guards vs type assertions

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

// DANGEROUS  -  no validation; TypeScript just trusts you
async function fetchUserUnsafe(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return res.json() as User; // runtime could be anything
}

// SAFER  -  manual type guard validates shape at runtime
function isUser(x: unknown): x is User {
  return (
    typeof x === 'object' &&
    x !== null &&
    typeof (x as Record<string, unknown>).id === 'number' &&
    typeof (x as Record<string, unknown>).name === 'string' &&
    typeof (x as Record<string, unknown>).email === 'string'
  );
}

async function fetchUserSafe(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data: unknown = await res.json();

  if (!isUser(data)) {
    throw new Error(`Invalid user shape: ${JSON.stringify(data)}`);
  }

  return data; // Now TypeScript knows data is User
}

Zod schema validation (runtime + compile-time types)

import { z } from 'zod';

// Define schema once  -  TypeScript type is inferred automatically
const UserSchema = z.object({
  id:    z.number().int().positive(),
  name:  z.string().min(1).max(100),
  email: z.string().email(),
  role:  z.enum(['admin', 'editor', 'viewer']),
  createdAt: z.string().datetime().optional(),
});

// Extract TypeScript type from the schema
type User = z.infer<typeof UserSchema>;
// { id: number; name: string; email: string; role: "admin" | "editor" | "viewer"; createdAt?: string }

// Safe API call with Zod validation
async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data = await res.json();

  // parse throws ZodError with detailed messages on failure
  return UserSchema.parse(data);
}

// safeParse  -  no throw, returns discriminated union
async function tryFetchUser(id: number) {
  const res  = await fetch(`/api/users/${id}`);
  const data = await res.json();
  const result = UserSchema.safeParse(data);

  if (result.success) {
    console.log(result.data.email); // User  -  fully typed
  } else {
    console.error(result.error.issues); // Validation errors
  }
}

Branded types for type-safe primitives

// Branded types prevent mixing up string/number primitives
type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId   = Brand<number, 'UserId'>;
type PostId   = Brand<number, 'PostId'>;
type Email    = Brand<string, 'Email'>;

// Constructor functions validate and brand the value
function UserId(id: number): UserId {
  if (!Number.isInteger(id) || id <= 0) throw new Error('Invalid UserId');
  return id as UserId;
}

function Email(email: string): Email {
  if (!email.includes('@')) throw new Error('Invalid email');
  return email.toLowerCase() as Email;
}

// Now functions can specify exactly which type they need
function getUser(id: UserId): { id: UserId; name: string } {
  return { id, name: 'Alice' };
}

function getPost(id: PostId): { id: PostId; title: string } {
  return { id, title: 'Hello' };
}

const userId = UserId(1);
const postId = 2 as PostId;

getUser(userId);  // OK
// getUser(postId);  // Error  -  PostId is not UserId
// getUser(1);       // Error  -  plain number is not UserId

Common Mistakes

  • Using `as` to type API responses - the TypeScript compiler trusts you but the runtime does not; a mismatched API response silently creates bugs that appear as "null is not an object" much later
  • Writing Zod schemas and TypeScript interfaces separately - use `z.infer<typeof Schema>` to derive the type from the schema and keep them in sync automatically
  • Overusing branded types - they add friction; reserve them for IDs and values that are commonly confused (UserId vs PostId, Celsius vs Fahrenheit, pixels vs ems)

Note

The single most impactful TypeScript pattern for preventing production bugs is **validate external data at the boundary**. Use Zod (or any schema library) at the point where data enters your app - API responses, form submissions, environment variables, file reads. Inside your app, trust the types; at the edges, verify them.

Exercises

Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.

Loading editor…

Completed: 0 / 3 exercises

Knowledge Check

Question 1 of 4

What does `const user = await response.json() as User` actually guarantee about the runtime data?