Open almost any codebase and you will find the same check repeated in five places: is this email non-empty, is this list non-empty, is this id a positive number. Each function defends itself because it cannot trust its caller, and each one throws a slightly different error when the check fails. Worse, the check is easy to forget in the sixth place, and that is where the bug lives.

The idea behind “parse, don’t validate”, popularized in the Haskell community, is simple: do not check data and then keep passing around the same loosely typed value. Instead, transform it once, at the boundary, into a type that can only hold correct values. After that, the rest of the program does not need to check, because incorrect states cannot be represented.

Validation Throws Information Away

Consider a function that validates:

function validateEmail(input: string): void {
  if (!input.includes("@")) throw new Error("Invalid email");
}

function sendWelcome(email: string) {
  validateEmail(email);
  // ... send
}

function addToNewsletter(email: string) {
  // forgot to validate
}

validateEmail returns nothing. After it succeeds, the compiler still sees a plain string, exactly as before. The knowledge that the string is a valid email lives only in the programmer’s head and in the control flow of one function. Every other function that receives a string must either re-validate or hope.

Now compare with parsing:

type Email = string & { readonly __brand: "Email" };

function parseEmail(input: string): Email {
  const trimmed = input.trim().toLowerCase();
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
    throw new Error("Invalid email");
  }
  return trimmed as Email;
}

function sendWelcome(email: Email) { /* ... */ }
function addToNewsletter(email: Email) { /* ... */ }

The function returns something. The output type is more precise than the input type, and that precision is the proof. A function that requires an Email cannot be called with a raw string; the compiler forces the caller to go through parseEmail first. Nobody can forget, because forgetting does not compile. For more on branded types and domain wrappers, see Value Objects and Branded Types in TypeScript.

The Core Principle: Make Illegal States Unrepresentable

A parse function has the shape (looser) => (stricter | failure). It does the checking and the conversion in one step, and its result carries the guarantee forward. Compare these two signatures for an operation that needs a non-empty list:

// Validation style: checks, but the type does not change
function average(values: number[]): number {
  if (values.length === 0) throw new Error("empty");
  return values.reduce((a, b) => a + b, 0) / values.length;
}

// Parsing style: the type says what is required
type NonEmptyArray<T> = [T, ...T[]];

function average(values: NonEmptyArray<number>): number {
  return values.reduce((a, b) => a + b, 0) / values.length;
}

function parseNonEmpty<T>(items: T[]): NonEmptyArray<T> | undefined {
  return items.length > 0 ? (items as NonEmptyArray<T>) : undefined;
}

In the second version, average has no failure path at all. The possibility of an empty list was dealt with where the list entered the system, once, by code that knows how to report the problem to a user. Inside the domain, the question never arises.

Parse at the Boundary

A boundary is anywhere data enters your program from the outside: an HTTP request body, a query string, environment variables, a message from a queue, a file, a database row from a loosely controlled schema, the result of JSON.parse. Everything crossing it is unknown, and should be treated that way.

Hand-writing parsers works, but a schema library makes them declarative. Zod is a popular choice:

import { z } from "zod";

const CreateOrderSchema = z.object({
  customerId: z.string().uuid(),
  items: z
    .array(z.object({ sku: z.string().min(1), quantity: z.number().int().positive() }))
    .nonempty(),
  couponCode: z.string().trim().toUpperCase().optional(),
  requestedAt: z.coerce.date(),
});

type CreateOrderInput = z.infer<typeof CreateOrderSchema>;

z.infer derives the static type from the schema, so the type and the runtime check cannot drift apart. Notice that this schema also transforms: it trims and uppercases the coupon, and converts a date string into a Date. That is parsing, not just validating. The output type differs from the input.

Use it at the edge of an HTTP handler:

import type { Request, Response } from "express";

export async function createOrderHandler(req: Request, res: Response) {
  const parsed = CreateOrderSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ errors: parsed.error.flatten() });
  }

  const order = await orderService.create(parsed.data); // typed, trusted from here
  return res.status(201).json(order);
}

orderService.create receives CreateOrderInput. It does not check that items is non-empty or that quantities are positive. It does not need to. If it did, that would be a sign the boundary is leaking.

Combine With Branded Types

Schemas can produce branded types directly, so the guarantee survives beyond the handler:

const Brand = <B extends string>() => <T>(value: T) => value as T & { readonly __brand: B };

const UserIdSchema = z.string().uuid().transform((s) => s as string & { readonly __brand: "UserId" });
const OrderIdSchema = z.string().uuid().transform((s) => s as string & { readonly __brand: "OrderId" });

type UserId = z.infer<typeof UserIdSchema>;
type OrderId = z.infer<typeof OrderIdSchema>;

function cancelOrder(userId: UserId, orderId: OrderId) { /* ... */ }

// cancelOrder(orderId, userId) is now a compile error: the arguments are swapped

Swapping two string ids is a classic bug that plain string types never catch. Branding turns it into a type error at no runtime cost.

Errors as Values at the Boundary

Throwing from a parser is acceptable in small scripts, but at a real boundary you usually want to collect every problem, not stop at the first. Returning a result type makes the failure part of the signature and forces the caller to handle it:

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

type FieldErrors = Record<string, string>;

function parseSignup(input: unknown): Result<{ email: Email; age: number }, FieldErrors> {
  const errors: FieldErrors = {};
  const raw = (input ?? {}) as Record<string, unknown>;

  let email: Email | undefined;
  try {
    email = parseEmail(String(raw.email ?? ""));
  } catch {
    errors.email = "Enter a valid email address";
  }

  const age = Number(raw.age);
  if (!Number.isInteger(age) || age < 13) errors.age = "You must be at least 13";

  if (Object.keys(errors).length > 0 || !email) return { ok: false, error: errors };
  return { ok: true, value: { email, age } };
}

This is the same idea explored in The Either and Result Pattern: failure is data, not a surprise. The boundary returns either a trusted value or a description of everything that was wrong.

The Same Idea in Python

Python’s type system is optional, but the principle holds. Pydantic parses untrusted input into typed models, and the rest of the code works with the model:

from pydantic import BaseModel, EmailStr, Field, ValidationError

class Signup(BaseModel):
    email: EmailStr
    age: int = Field(ge=13)
    tags: list[str] = Field(default_factory=list, max_length=10)

def register(raw: dict) -> Signup:
    try:
        return Signup.model_validate(raw)
    except ValidationError as exc:
        raise BadRequest(exc.errors()) from exc

def welcome(user: Signup) -> str:
    return f"Welcome, {user.email}"  # no re-checking

For a lighter alternative, a frozen dataclass with a from_raw classmethod that performs checks in __post_init__ gives the same effect: the only way to obtain the object is through a constructor that enforces the rules. See Python Dataclasses, Pydantic and attrs for a comparison.

Parse Once, Then Trust the Types

The discipline has a clear structure:

  1. Outside the boundary, data is unknown. Nothing may use it directly.
  2. At the boundary, a parser converts it to a precise domain type or reports errors.
  3. Inside the boundary, functions take precise types and contain no defensive checks for properties the type already guarantees.

Defensive checks deep in the domain are a smell. They usually mean the type is too loose, and the fix is to tighten the type at the edge, not to add another if.

A useful exercise: find a function with a throw for invalid input, then ask which caller could have prevented it. If the answer is “the code that received the data”, the parsing belongs there.

Refactoring Toward Parsing

You do not need a rewrite to adopt this. Start at one boundary, for example a single HTTP endpoint. Define its schema, parse the body, and change the first function that receives the data to accept the parsed type. The compiler will now point at every caller that passes something looser. Work inward from there, deleting defensive checks as the types make them redundant. Each deleted if is evidence that the invariant has moved to a single, well-tested place.

A good habit is to treat parser functions as part of your domain model and unit test them thoroughly with both valid and invalid input, since every other function now relies on them. Property-based tests are a natural fit: generate arbitrary strings and assert that either parsing fails or the resulting value satisfies its invariants.

Pitfalls

  • Parsing and then discarding. If you call parse but keep using the original raw object, you gained nothing. Use the parser’s output.
  • Casting instead of parsing. body as CreateOrderInput asserts a type without checking anything. It is the opposite of parsing. Keep as confined to the inside of parser functions, as in the branded examples above.
  • Branding without a gate. A branded type is only meaningful if the single way to create one is a function that enforces the rule. Export the type and the parser, never a bare constructor.
  • Over-modeling. Not every string needs a brand. Reserve precise types for values with real invariants: ids that must not be mixed, money, emails, bounded numbers, state machines.
  • Validating twice differently. If the frontend, API and database each have their own rules, they drift. Share the schema where you can.
  • Leaking parse errors. Return structured, user-friendly messages at the boundary, not raw library exceptions.

When Validation Alone Is Fine

Some constraints cannot be captured in a type: a username must be unique, a coupon must not be expired, an account must have sufficient balance. Those depend on external state and may change between check and use, so they remain runtime checks, ideally enforced atomically in the database. Parsing handles structure and local invariants; it does not replace business rules that depend on the world.

Also, in a tiny script or a one-off prototype, a couple of inline checks are perfectly reasonable. The approach pays off as the number of call sites grows.

Key Takeaways

  • Validation checks and forgets; parsing checks and returns a more precise type that carries the proof forward.
  • Treat all external input as unknown and convert it once, at the boundary.
  • Derive static types from runtime schemas so they cannot drift apart.
  • Branded and non-empty types turn whole classes of bugs, such as swapped ids, into compile errors.
  • Defensive checks deep in the domain mean the type is too loose; fix it at the edge instead.
  • Keep runtime business rules that depend on external state as explicit checks.