DEV Community

Paradane
Paradane

Posted on

Parse, Don't Validate in TypeScript: A Practical Tutorial

Parse, Don't Validate in TypeScript: A Practical Tutorial

The core idea of Parse, Don't Validate is simple: instead of scattering ad‑hoc checks throughout your codebase, you validate external data once at the system boundary and transform it into a trusted, strongly typed internal representation. Traditional validation patterns – like using type assertions (as) or validation functions that return booleans – leave you with hidden assumptions. A function might check that a value is a string, but later code still needs to confirm it's a valid email. This creates a fragile chain of assumptions where a single missed check can introduce a bug. TypeScript is excellent at compile‑time safety, but types vanish at runtime. Any value from an API response, a form submission, or localStorage can carry a different shape than what you declared. Without a parse step, those mismatches slip through and cause crashes. In this tutorial, you'll learn how to build a parse‑first pattern using Zod for schema‑based parsing and branded types to enforce domain rules at the type level. By the end, you'll have a reliable data flow that eliminates entire categories of runtime errors.

The Hidden Cost of Validation in TypeScript Apps

Many TypeScript projects rely on validation functions that return a boolean—for example, isUser(obj: unknown): boolean. This approach gives a warm feeling of safety, but the truth is that after the check, the developer still has to cast the data to the expected type using as. That cast bypasses TypeScript's compile-time checks and introduces a gap between the validated boolean and the actual runtime shape.

Consider this common pattern:

function isUser(data: unknown): boolean {
  if (typeof data !== 'object' || data === null) return false;
  const obj = data as Record<string, unknown>;
  return typeof obj.name === 'string' && typeof obj.email === 'string';
}

function processUser(raw: unknown) {
  if (isUser(raw)) {
    const user = raw as { name: string; email: string };
    console.log(user.name.toUpperCase()); // may crash later
  }
}
Enter fullscreen mode Exit fullscreen mode

The isUser function checks a few properties, but it doesn't ensure the exact shape. The as assertion tells TypeScript "I know better," which is exactly where bugs hide.

Now imagine an API response that used to include email but the backend renamed it to emailAddress or removed it. The validation function still returns true because it only checks typeof obj.email === 'string'—but if email is undefined, typeof undefined is 'undefined', so that condition fails. Wait, actually in the example above, if email is missing, typeof obj.email === 'string' would be false, so validation would return false. But that's good. However, the bug is more subtle: what if a new field appears? The validation doesn't catch required fields that are missing if the check is incomplete. For example, if the API changes the spelling to emailAddress, the original email property becomes undefined, so validation fails correctly. But the real danger is when developers add optional checks or use partial interfaces.

A more realistic bug: the API returns a nested object, e.g., user.profile.name, and validation only checks top-level fields. After the boolean validation, code accesses user.profile.name assuming profile exists, but the API response changed shape so profile is null. The type assertion as { profile: { name: string } } lies to TypeScript, and at runtime you get Cannot read properties of null.

This is why validation functions that return booleans are fundamentally unreliable. They don't transform the data; they only give a binary pass/fail. The developer must still assume the shape is correct, and any mismatch between the validation logic and the actual data leads to undefined behavior.

Furthermore, type assertions like as are often used directly on API responses without any validation:

const user = await fetchUser() as User;
Enter fullscreen mode Exit fullscreen mode

This is the worst offender—you've completely disabled type checking for that variable. If the API changes, TypeScript won't warn you, and you'll discover the bug only when a production user hits a missing property.

The result is scattered checks throughout the codebase: a validation here, an as cast there, a runtime guard in another method. None of them give you a single source of truth for what data is trusted. This fragmentation is the hidden cost—wasted debugging time and brittle applications.

In the next section, we'll see how parsing with Zod eliminates these issues by creating a trusted data boundary.

Why Parsing Changes the Game: Trusted Data Flow

Traditional validation functions return a boolean—true or false—but leave the original unknown or any value untouched. The developer must then sprinkle type assertions (as User) throughout the codebase, assuming the data is correct. One wrong assumption and a runtime crash slips through.

Parsing flips this dynamic. Instead of asking “is this valid?”, parsing answers “turn this unknown input into a trusted, strongly-typed value, or tell me exactly what’s wrong.” The result is a single boundary where data is checked and transformed, and after that boundary, every piece of code can work with known, safe data.

The Trusted Boundary

Imagine a gate that all external data must pass through:

[External Data] ──> [Parse Function] ──> [Trusted Internal Data]
                           │
                           └──> Error (detailed failure)
Enter fullscreen mode Exit fullscreen mode

Once data crosses this boundary, the rest of your application never needs to re-validate or re-check. This dramatically reduces the surface area for bugs.

Parsing vs Validation: Side-by-Side

Here’s a typical validation-only approach, fraught with risk:

// Validation-only: returns boolean
function isUserValid(data: unknown): data is User {
  if (typeof data !== 'object' || data === null) return false;
  const obj = data as Record<string, unknown>;
  return typeof obj.name === 'string' &&
         typeof obj.email === 'string' &&
         typeof obj.age === 'number';
}

// Later, still need assertion:
const raw = JSON.parse(apiResponse);
if (isUserValid(raw)) {
  const user = raw as User;  // Trust me, bro
  processUser(user);
} else {
  throw new Error('Invalid user');
}
Enter fullscreen mode Exit fullscreen mode

The isUserValid function returns a boolean and uses a type predicate (data is User), but the check is shallow and the as User assertion is brittle. A change in the API shape (e.g., age becomes optional) will silently pass validation but fail at runtime.

Now compare with parsing using a discriminated union return:

import { z } from 'zod';

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().int().positive(),
});

type User = z.infer<typeof UserSchema>;

// Parse function returns a discriminated union
type ParseResult<T> =
  | { success: true; data: T }
  | { success: false; error: z.ZodError };

function parseUser(raw: unknown): ParseResult<User> {
  const result = UserSchema.safeParse(raw);
  if (result.success) {
    return { success: true, data: result.data };
  } else {
    return { success: false, error: result.error };
  }
}

// Usage:
const raw = JSON.parse(apiResponse);
const parsed = parseUser(raw);

if (parsed.success) {
  // parsed.data is fully typed, no assertion needed
  processUser(parsed.data);
} else {
  console.error('Validation failed:', parsed.error.issues);
}
Enter fullscreen mode Exit fullscreen mode

The parsing approach guarantees that any value reaching processUser has passed a comprehensive schema check. The discriminated union (success property) makes the code both type-safe and explicit about error handling. No more hidden as User casts.

Why This Matters

Parsing establishes a trusted data flow. External data enters the system, is transformed into a reliable internal representation, and from that point onward, developers can reason about the program without worrying about the shape or validity of that data. This is the core of the “Parse, Don’t Validate” philosophy: parse once at the boundary, and let the type system carry the trust inward.

In the next section, we’ll introduce branded types to encode even deeper business rules into the parsed types.

Tooling Up: Using Zod for Runtime Parsing

Now that we understand the value of establishing a trusted boundary, it's time to put it into practice with a library designed for parsing: Zod. Zod is a TypeScript-first schema declaration and validation library that excels at runtime checking. It lets you define the shape and constraints of your data once, then automatically infers the corresponding TypeScript type. This eliminates the disconnect between your runtime checks and your static types.

First, install Zod in your project:

npm install zod
Enter fullscreen mode Exit fullscreen mode

or with yarn:

yarn add zod
Enter fullscreen mode Exit fullscreen mode

Let’s define a schema for a typical entity, a User with an email, name, and age. With Zod, you build a schema using z.object:

import { z } from 'zod';

export const UserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1, 'Name is required'),
  age: z.number().int().positive(),
});
Enter fullscreen mode Exit fullscreen mode

The magic here is that Zod automatically infers the TypeScript type from this schema. You can extract it with z.infer:

export type User = z.infer<typeof UserSchema>;
// { email: string; name: string; age: number }
Enter fullscreen mode Exit fullscreen mode

Now you have both a runtime validator and a compile-time type that are guaranteed to stay in sync. If you later change the schema (e.g., add a role field), the type updates automatically.

To parse incoming data, use safeParse. It never throws; instead it returns a discriminated union { success: true; data: User } or { success: false; error: ZodError }. This is ideal for dealing with untrusted input like an API response:

const rawUser: unknown = { email: 'alice@example.com', name: 'Alice', age: 30 };

const result = UserSchema.safeParse(rawUser);

if (result.success) {
  // result.data is of type User – fully trusted
  console.log(result.data.email);
} else {
  // result.error contains formatted details about each field failure
  console.error(result.error.flatten());
}
Enter fullscreen mode Exit fullscreen mode

With safeParse, you handle errors explicitly and never face unexpected crashes. The ZodError object provides a list of issues, each with a path, message, and error code—ideal for building user-friendly error messages or logging. This contrasts with traditional validation functions that might return true or false and leave you guessing what went wrong.

Zod also supports refinements and transformations, allowing you to go beyond simple type checks. For example, you could transform the email string into a branded type (which we’ll cover next). For now, the key takeaway is: parsing with Zod turns unknown, dangerous input into a trusted, strongly-typed representation at the earliest possible point. You can now build your business logic on that parsed data with confidence.

Branded Types: When Parsing Meets Domain Logic

Zod schemas ensure that your data conforms to a shape, but they don't enforce business rules at the type level. For example, an email field in a Zod schema is validated to be a string with a valid email format, but after parsing, TypeScript sees it purely as a string. Any string will be accepted wherever your User.email is used, even if that string didn't pass the email validator. This is where branded types come in.

A branded type is a nominal type simulation in TypeScript. It uses a unique symbol to create a distinct type that is only assignable from values that have gone through a specific constructor—in our case, a parser function. The pattern looks like this:

type EmailBrand = { readonly __brand: 'Email' };
export type Email = string & EmailBrand;
Enter fullscreen mode Exit fullscreen mode

Now, no arbitrary string can be assigned to Email; you must go through a function that produces the brand. That function is your parser, which runs the Zod validation and then stamps the brand onto the result:

import { z } from 'zod';

const emailSchema = z.string().email();

function parseEmail(raw: unknown): Email {
  const parsed = emailSchema.parse(raw);
  return parsed as Email;
}
Enter fullscreen mode Exit fullscreen mode

Once you have a branded Email, you can write functions that are strict about their inputs:

function sendWelcomeEmail(email: Email): void {
  // TypeScript guarantees this email passed validation
  console.log(`Sending welcome to ${email}`);
}

const rawInput = 'user@example.com';
const email = parseEmail(rawInput);
sendWelcomeEmail(email); // ✅ Works

// sendWelcomeEmail('not-an-email'); // ❌ TypeScript error: Argument of type 'string' is not assignable to 'Email'
Enter fullscreen mode Exit fullscreen mode

This pattern enforces that only parsed data flows into your domain logic. In a real application, you might have multiple branded types like UserId or PhoneNumber. The key is that parsing becomes the only gateway to create these types. When you combine branded types with Zod's safeParse, you get a robust system where every error is caught at the boundary, and the internal code never has to re-check data integrity. This reduces the surface for bugs and makes your codebase’s data flow transparent.

Branded types shine when you have distinct business concepts that carry validation rules. For example, an Email and a Username are both strings, but they have different validation requirements. Using brands forces the compiler to distinguish them, preventing mix-ups. This is especially valuable in larger codebases where a developer might accidentally pass a username where an email is expected. The brand turns a runtime bug into a compile-time error.

In practice, you can define parsers for each domain type and re-export them as part of a shared library. The rest of your application then imports Email and parseEmail instead of raw strings. Over time, this cultivates a culture of type safety and reduces the number of defensive if checks scattered across your functions. As you adopt this pattern, you'll notice that your code becomes more declarative about what it expects, and the compiler becomes a stronger partner in preventing errors.

Common Pitfalls and How to Avoid Them

Adopting a parse-don't-validate mindset brings significant reliability gains, but even experienced developers can stumble. Here are the most common pitfalls and how to sidestep them.

Pitfall 1: Parsing Too Late

The classic mistake is trusting TypeScript's static types and deferring parsing until deep inside your business logic. By then, invalid data has already propagated through multiple layers, making it nearly impossible to trace the root cause.

Before: parsing too late

function processUser(raw: unknown) {
  // Assume data is valid because TypeScript doesn't complain
  const user = raw as User;
  // Later, a property access crashes
  console.log(user.email.toLowerCase()); // runtime error if email is missing
}
Enter fullscreen mode Exit fullscreen mode

After: parse at the boundary

function parseUser(raw: unknown): User {
  return userSchema.parse(raw);
}

function processUser(raw: unknown) {
  const user = parseUser(raw); // Trusted from this point on
  console.log(user.email.toLowerCase()); // Safe
}
Enter fullscreen mode Exit fullscreen mode

Always parse as soon as data enters your system—at the API handler, form submission handler, or storage read. Never pass unknown further than necessary.

Pitfall 2: Ignoring Parse Errors

When developers use .parse() without catching errors, or call .safeParse() but ignore the error field, parse failures silently become runtime errors downstream. This defeats the entire purpose of parsing.

Before: ignoring parse errors

function handleRequest(raw: unknown) {
  const result = userSchema.safeParse(raw);
  if (!result.success) {
    // Silently swallowing the error - data is corrupted
    return;
  }
  // Later, processedData might be undefined or partially correct
}
Enter fullscreen mode Exit fullscreen mode

After: structured error handling

interface ParseError {
  code: string;
  message: string;
  path: (string | number)[];
}

function handleRequest(raw: unknown) {
  const result = userSchema.safeParse(raw);
  if (!result.success) {
    const issues: ParseError[] = result.error.issues.map(issue => ({
      code: issue.code,
      message: issue.message,
      path: issue.path,
    }));
    // Log or return structured errors
    console.error('Validation failed:', issues);
    throw new ParseException(issues);
  }
  // Safe to use result.data
}
Enter fullscreen mode Exit fullscreen mode

Always handle the error case explicitly and preserve the structured error details for debugging or user-facing messages.

Pitfall 3: Not Structuring Error Types

Many teams treat parse errors as generic Error objects, losing the rich detail Zod provides. This makes it hard to pinpoint failures in complex systems.

Define a dedicated ParseError type—as shown above—that includes the error code, human-readable message, and path to the invalid field. This pattern enables precise error reporting and automated testing of parsing logic. For example, you can write tests that assert specific issues at specific paths, ensuring your schema correctly rejects invalid inputs.

Pitfall 4: Over-Parsing Internal Data

Once data is trusted, re-parsing it internally violates the principle. For instance, don't re-validate a User object that already came from a trusted source. Over-parsing adds unnecessary overhead and complicates code.

Instead, design your system so that the parsing step happens exactly once at the boundary, and all internal code works with the trusted types. This keeps your codebase clean and performant.

By avoiding these traps, you'll get the full benefit of the parse-don't-validate pattern: fewer runtime errors, clearer failure modes, and a codebase that makes invalid states unrepresentable.

Integrating Parsing into Your Product Workflow

Adopting a parse-don't-validate approach requires thoughtful integration into your existing architecture. The key is to establish parsing layers at every system boundary where external data enters your application, ensuring that trusted, strongly-typed data flows through the rest of your codebase.

Parsing in the API Layer

The most common entry point for external data is an API handler. Instead of scattering validation checks throughout your controller, parse the entire request body at the gateway. Here's a concise example using Express and Zod:

import { z } from 'zod';
import { Request, Response } from 'express';

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  age: z.number().int().positive(),
});

type CreateUserInput = z.infer<typeof CreateUserSchema>;

async function createUserHandler(req: Request, res: Response) {
  const result = CreateUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      error: 'Invalid input',
      details: result.error.flatten().fieldErrors,
    });
  }

  // result.data is now fully typed and trusted
  const user = await createUser(result.data);
  res.status(201).json(user);
}
Enter fullscreen mode Exit fullscreen mode

This pattern eliminates the need for manual type assertions (as) and scattered checks. Every handler that receives external input becomes a single point of transformation: from unknown to a domain-typed object.

Service Layer vs Controller Responsibility

A common question is where parsing should live. The controller (or API gateway) is responsible for parsing and returning errors to the caller. The service layer should never parse — it should receive already-validated, typed objects. This separation keeps your business logic clean and testable:

  • Controller: Parses input, handles validation errors, calls service.
  • Service: Assumes trusted data, enforces domain logic (e.g., business rules beyond schema).
  • Repository: Handles persistence, often with its own parsing for database records.

In practice, this means your service functions accept strongly-typed inputs without further validation, reducing duplication and improving readability.

Parsing in Data Migration Scripts

Data migrations (e.g., reading from localStorage, CSV imports, legacy database exports) are notorious for hidden bugs when shape assumptions break. By parsing at the start of each migration, you catch errors early and keep transformation logic predictable:

import { z } from 'zod';

const LegacyUserSchema = z.object({
  email: z.string(),
  full_name: z.string(),
});

type LegacyUser = z.infer<typeof LegacyUserSchema>;

function migrateUsers(rawData: unknown[]): LegacyUser[] {
  return rawData.map((item) => LegacyUserSchema.parse(item));
}
Enter fullscreen mode Exit fullscreen mode

If a field is missing or malformed, Zod throws a detailed error, preventing silent corruption downstream.

Parsing in Forms (Client-Side)

On the frontend, form submissions are another boundary. Using the same Zod schemas on both client and server ensures consistency:

const result = CreateUserSchema.safeParse(formData);
if (!result.success) {
  setErrors(result.error.flatten().fieldErrors);
  return;
}
// result.data is safe to send to the API
Enter fullscreen mode Exit fullscreen mode

This pattern reduces code duplication by reusing schemas across boundaries.

Testing Strategies for Parsers

Because parsers are deterministic and side-effect-free, they are easy to unit test. Focus on three scenarios:

import { UserSchema } from './schemas';

describe('UserSchema', () => {
  it('parses valid input', () => {
    const input = { email: 'test@example.com', name: 'Alice', age: 30 };
    expect(() => UserSchema.parse(input)).not.toThrow();
  });

  it('rejects invalid email', () => {
    const input = { email: 'not-an-email', name: 'Alice', age: 30 };
    const result = UserSchema.safeParse(input);
    expect(result.success).toBe(false);
  });

  it('rejects missing required fields', () => {
    const input = { name: 'Alice' };
    const result = UserSchema.safeParse(input);
    expect(result.success).toBe(false);
  });
});
Enter fullscreen mode Exit fullscreen mode

Test edge cases: null values, unexpected types, empty strings. Because Zod schemas define constraints declaratively, these tests verify that your boundary logic works as intended.

Reducing Code Duplication and Improving Consistency

When parsing is centralized, every data entry point follows the same pattern: parse at the boundary, pass trusted objects inward. This eliminates repetitive if-else checks, reduces the chance of missing validation in some code paths, and makes error handling uniform. Teams adopting this approach report fewer runtime errors related to data shape assumptions.

For larger projects, consider introducing a dedicated "parsing layer" as part of your clean architecture. Services remain focused on business logic, and the parsing layer acts as a gatekeeper, ensuring that only valid, typed data enters core operations.

From Theory to Your Next Project

You’ve seen how parse-don’t-validate transforms messy input into trusted, typed data. Now it’s time to apply it to a real project. Start small: pick one data entry point—like a signup endpoint, an API client, or a form handler—and refactor it with a Zod schema. Define the expected shape, use safeParse to handle errors, and introduce a branded type for any value that carries domain meaning, such as UserId or Email. Once the boundary is solid, you’ll notice how the rest of the codebase becomes simpler, because you no longer pepper every function with if checks or type assertions.

This shift builds momentum. After your first endpoint, you’ll naturally want to extend the pattern to other system boundaries: database reads, file imports, configuration loading. Each boundary you parse reduces the chance of a production crash from unexpected data. If you need guidance designing robust architectures or implementing these patterns in real products, Paradane provides expert support to help your team adopt parse-first practices effectively. Visit https://paradane.com to learn more.

The hardest part is the first step. Pick one file, write one schema, and let the trust propagate.

Top comments (0)