Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 056 / 100

Runtime validation for JavaScript & TypeScript developers

TypeScript can't check data from the outside world.

Expona Admin9 min read

Runtime validation

TypeScript gives you type safety at compile time, but the moment data enters your application from the outside world, those guarantees evaporate. Runtime validation is the practice of checking that incoming data actually matches what your code expects, before you try to use it. Without it, you'll spend hours debugging mysterious crashes caused by malformed API responses, corrupted environment variables, or stale data from local storage.

The gap between compile time and runtime

Here's the hard truth: TypeScript's type system doesn't exist at runtime. When your code runs in a browser or on a server, all those types are gone—stripped away during the build step. This means you can declare a function that expects a User object with an id number and an email string, but nothing stops someone from passing in {id: "wrong", email: null} at 3 AM on a Sunday.

I learned this the hard way early in my career. We had a trading platform that relied on WebSocket messages from an external provider. Our TypeScript types said each message would have a price: number. One day, the provider sent price: "N/A" during a market anomaly, and our entire calculation engine crashed because we never validated at the boundary.

Where to validate — API requests, responses, env vars, local storage and messages

Not all data is created equal. Some comes from users directly, some from external APIs, some from your own config files. Each source deserves validation, but each needs it at a different point in your flow.

API requests from clients

When a browser sends a POST request to your server, validate the body immediately. This is your first line of defense. A user could send anything—malicious, malformed, or just wrong.

typescript
// Express + Zod example
import { z } from "zod";
import express, { Request, Response } from "express";

const CreateUserSchema = z.object({
  email: z.string().email("Invalid email"),
  age: z.number().int().min(18, "Must be 18+"),
  name: z.string().min(1),
});

type CreateUserInput = z.infer<typeof CreateUserSchema>;

app.post("/users", (req: Request, res: Response) => {
  const result = CreateUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({ errors: result.error.flatten() });
  }

  const user = result.data; // Now TypeScript knows the shape
  // proceed with user creation
});

API responses from external services

You don't control third-party APIs. They change, they have bugs, they return unexpected nulls. Always validate responses before using them.

typescript
// Stripe API response validation
import { z } from "zod";

const StripeChargeSchema = z.object({
  id: z.string(),
  amount: z.number(),
  currency: z.string(),
  status: z.enum(["succeeded", "failed", "pending"]),
});

type StripeCharge = z.infer<typeof StripeChargeSchema>;

async function processPayment(stripeResponse: unknown) {
  const result = StripeChargeSchema.safeParse(stripeResponse);

  if (!result.success) {
    console.error("Stripe response malformed:", result.error);
    throw new Error("Payment processing failed");
  }

  const charge: StripeCharge = result.data;
  // Now safe to use charge.amount, charge.status, etc.
}

Environment variables

Env vars are strings. If your code expects a number or a boolean, validate it. Typos in .env.production will silently break your app otherwise.

typescript
// Validate env vars at startup
import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]),
  PORT: z.string().pipe(z.coerce.number().int().min(1).max(65535)),
  DATABASE_URL: z.string().url("Invalid database URL"),
  API_TIMEOUT_MS: z.string().pipe(z.coerce.number().positive()).optional(),
});

const env = EnvSchema.parse(process.env);

// env.PORT is now definitely a number, env.NODE_ENV is a literal union
export default env;

Local storage and session storage

Browsers let users modify local storage. If your React app stores user preferences or a cached user object in localStorage, validate it when you read it back.

typescript
// React hook for reading stored preferences
import { z } from "zod";

const UserPreferencesSchema = z.object({
  theme: z.enum(["light", "dark"]).default("light"),
  language: z.string().default("en"),
  sidebarCollapsed: z.boolean().default(false),
});

type UserPreferences = z.infer<typeof UserPreferencesSchema>;

export function usePreferences() {
  const [prefs, setPrefs] = useState<UserPreferences>({
    theme: "light",
    language: "en",
    sidebarCollapsed: false,
  });

  useEffect(() => {
    const stored = localStorage.getItem("userPreferences");
    if (stored) {
      const result = UserPreferencesSchema.safeParse(JSON.parse(stored));
      if (result.success) {
        setPrefs(result.data);
      }
      // If parsing fails, we keep defaults—no crash
    }
  }, []);

  return { prefs, setPrefs };
}

WebSocket and real-time messages

Every message from a WebSocket is untrusted data. In my ride-hailing platform, driver location updates came as messages—one malformed JSON and the entire tracking service would fail.

typescript
// WebSocket message validation
import { z } from "zod";

const DriverLocationUpdateSchema = z.object({
  driverId: z.string().uuid(),
  latitude: z.number().min(-90).max(90),
  longitude: z.number().min(-180).max(180),
  timestamp: z.string().datetime(),
});

type DriverLocationUpdate = z.infer<typeof DriverLocationUpdateSchema>;

socket.on("driverLocationUpdate", (data: unknown) => {
  const result = DriverLocationUpdateSchema.safeParse(data);

  if (!result.success) {
    console.warn("Invalid location update:", result.error);
    return; // Silently ignore malformed messages
  }

  const update: DriverLocationUpdate = result.data;
  updateDriverOnMap(update);
});

Validation libraries — Zod, Valibot, ArkType or TypeBox

You don't want to write manual validation logic for every schema. Modern TypeScript ecosystems have excellent declarative validation libraries.

Zod is the most popular. It's intuitive, has great error messages, and works in both Node and browsers. I've used it on most recent projects.

Valibot is newer and lighter—if bundle size matters for your frontend, it's worth comparing. It has excellent docs and a similar API to Zod.

ArkType focuses on simplicity and speed. It leans into TypeScript's type narrowing heavily.

TypeBox is built on JSON Schema standards, useful if you need to generate OpenAPI docs or share schemas across systems.

For most projects, Zod is the safest bet. Here's a comparison of how each handles the same schema:

Library Pros Cons Bundle size
Zod Best DX, great errors, widely used Slightly larger bundle ~15KB gzipped
Valibot Tiny, tree-shakeable, modern Newer, smaller community ~5KB gzipped
ArkType Very fast parsing, elegant syntax Smaller community, less mature ~20KB gzipped
TypeBox JSON Schema compliant, fast More verbose, less intuitive ~8KB gzipped

Fail loudly — Invalid data should error at the boundary, not deep in your code

The best place to catch an invalid input is immediately at the edge—the API endpoint, the message handler, the config loader. If you let bad data seep into your business logic, you'll spend hours tracing a null pointer exception back to a missing field three layers deep.

Your validation response should be clear. Return HTTP 400 (Bad Request) with details about what went wrong. Log it for debugging. Throw an error if you're processing background jobs.

typescript
// Good: Fail at the boundary with a clear error
app.post("/transfer", (req: Request, res: Response) => {
  const result = TransferSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      code: "INVALID_INPUT",
      message: "Request validation failed",
      details: result.error.flatten().fieldErrors,
    });
  }

  const transfer = result.data;
  // Proceed knowing transfer is valid
  performTransfer(transfer);
});

// Bad: Let bad data through and fail later
app.post("/transfer", async (req: Request, res: Response) => {
  const transfer = req.body; // No validation!

  // This crashes with cryptic "Cannot read property 'amount' of undefined"
  const fee = calculateFee(transfer.amount);
  await db.transfers.create(transfer);
});

Performance — Validate once at the edge, trust inside

Validation has a cost. Each safeParse call does work. But the cost of not validating—debugging runtime crashes, data corruption, security issues—is far higher.

The strategy is simple: validate once at the entry point, then trust the data inside. Once you've passed a schema check, use the validated type without re-checking.

typescript
// Validate at entry, trust inside
async function handleUserRegistration(req: Request) {
  // Single validation at the boundary
  const result = RegisterUserSchema.safeParse(req.body);
  if (!result.success) throw new ValidationError(result.error);

  const input = result.data;

  // Now pass the validated data to business logic
  const user = await createUser(input); // input is trusted
  await sendWelcomeEmail(input.email); // email is definitely a string
  await logUserCreated(user.id); // id is definitely a uuid

  return user;
}

// Don't re-validate inside functions
function createUser(input: z.infer<typeof RegisterUserSchema>) {
  // input.email is already validated as email format
  // input.password is already validated as min 12 chars
  // No need to check again
  return db.users.create({
    email: input.email,
    passwordHash: hashPassword(input.password),
  });
}

For high-throughput APIs, consider caching validation results if you're checking the same object multiple times. In my e-commerce platform, order messages came through Kafka, and we validated once at consumption time, then trusted the data for all downstream processing.

Common mistakes

  • Forgetting to validate external API responses. You own your own APIs, but not third-party ones. Always validate.
  • Validating at every layer. Validate once at the boundary, then trust. Re-validating is wasteful.
  • Using loose error handling. try-catch around parsing encourages bugs. Use safeParse and check the result explicitly.
  • Storing validation schemas in tests only. Keep schemas in source code where they're easy to find and version.
  • Ignoring type narrowing after validation. After passing a Zod schema, TypeScript knows the shape. Use z.infer to get the type and trust it.
  • Not validating env vars at startup. Missing or wrong env vars should crash immediately on boot, not hours later in production.

Key takeaways

  • TypeScript types disappear at runtime. Validate data at the boundary—API requests, responses, env vars, local storage, WebSocket messages.
  • Use a library like Zod. Don't write manual validation; it's error-prone and verbose.
  • Fail loudly and immediately. Catch invalid data at the entry point, not three layers deep in your code.
  • Validate once at the edge, trust inside. Don't re-validate the same data in every function.
  • Choose the right tool for your constraints. Zod is the safest default; Valibot if bundle size is critical.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles