Parse, Don't Validate: An Architecture Principle for TypeScript
AI generated
<T>
type
TypeScript · Parse Don't Validate · Zod · Architecture
Parse, Don't Validate
An architecture principle for robust TypeScript applications

A plain validation function that only returns true or false discards the one piece of information it just gained through the check, and forces the rest of the code to repeat the same check at every other point. This article explains the parse, don't validate principle and shows, through refinement types, nested parsers, and practical TypeScript examples, how to permanently remove unknown states from a codebase instead of re-checking them over and over.

13 min read Parse don't validate · Refinement types Zod 3.x · TypeScript 5.x

1. Parse, don't validate: understanding the core principle

The parse, don't validate principle distinguishes two fundamentally different reactions to untrusted input data. Validation checks whether a value satisfies a condition and returns a plain boolean, immediately discarding the knowledge gained about the value's actual structure. A parser, by contrast, transforms an untrusted input value into a new, more precise type that permanently captures the insight from the check in the type system.

This difference looks like a mere formality at first, but it has far reaching consequences for a codebase. Anyone who consistently parses instead of merely validating only ever needs to perform a given check once, at the point where untrusted data enters the application. Every subsequent function works with an already refined type and never needs to repeat the same check, a structural advantage that plain validation fundamentally cannot offer.

2. Why plain validation discards knowledge already gained

A typical validation function looks like function isValidEmail(value: string): boolean. After a successful call, the caller knows that the condition held at the moment of the check, but the type of value remains unchanged as string. Every further function receiving the same value cannot rely on this check and must either validate again or blindly trust that the caller already validated, an assumption that rarely holds reliably in growing codebases.

In practice, this pattern produces a growing number of redundant checks appearing everywhere a value gets reused, simply because nobody feels confident that the original validation still applies. Plain validation only answers the question of whether a value was valid at a specific moment, not whether it still is at every later point in the code, a difference that matters especially with mutable data structures or multi stage processing pipelines.


// Validation: knowledge from the check is discarded immediately
function isValidEmail(value: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}

function sendWelcomeEmail(rawEmail: string): void {
  if (!isValidEmail(rawEmail)) {
    throw new Error("Invalid email");
  }
  // rawEmail is still just "string" here, the check result is gone
  dispatchEmail(rawEmail);
}

function dispatchEmail(email: string): void {
  // This function has no compile-time guarantee that "email" was checked
  // A caller could pass any string here without ever calling isValidEmail
}

3. Refinement types: anchoring information in the type itself

A refinement type solves this problem by making the insight from a successful check visible directly in the type system, instead of losing it in a fleeting boolean. Instead of a function returning true or false, a parser defines a new, narrower type, such as ValidatedEmail, that can only be produced through a controlled parsing step.

The decisive difference from branded types alone is that a refinement type is not just nominally distinguishable, its very existence provides the guarantee that the underlying condition was actually satisfied. A function expecting a ValidatedEmail parameter can fully rely on this value having already been checked, without knowing or repeating the check itself.


import { z } from "zod";

// The schema IS the parser: valid emails get their own distinguishable type
const validatedEmailSchema = z.string().email().brand<"ValidatedEmail">();
type ValidatedEmail = z.infer<typeof validatedEmailSchema>;

// Parsing: converts an unsafe string into a proven ValidatedEmail
function parseEmail(rawEmail: string): ValidatedEmail {
  return validatedEmailSchema.parse(rawEmail);
}

function dispatchEmail(email: ValidatedEmail): void {
  // The parameter type itself is proof that this value was already checked
  console.log(`Sending to ${email}`);
}

function sendWelcomeEmail(rawEmail: string): void {
  const email = parseEmail(rawEmail); // throws if invalid, narrows if not
  dispatchEmail(email); // no re-validation possible, no re-validation needed
}

4. A parser as a function from unknown to a refined type

Conceptually, a parser can be described as a function with the signature (input: unknown) => T, which either returns a value of the precise type T or throws an exception, instead of stopping at a boolean intermediate step. This definition makes clear why parse, don't validate is not a library specific concept, it is a general architecture principle that can be implemented just as well with Zod, Valibot, or even hand written functions.

Zod's .parse() method is a direct example of this signature, as is any hand written function that takes unknown and returns either a specific type or an exception. It is important that a parser never returns an unchanged input type, otherwise it would merely be validation with an added exception mechanism, not a real refinement step in the sense of the principle.

5. Parsing once at the boundary instead of re-checking everywhere

The practical consequence of parse, don't validate is to perform the parsing step as early as possible, right at the boundary where untrusted data enters the application, whether a request body, a configuration file, or an external API response. From this point on, the parsed value carries a precise type, and every subsequent function in the call graph benefits from this guarantee without having to establish it itself.

This pattern stands in direct contrast to an architecture where every function checks for itself whether its input is valid, a style that leads to redundant, often slightly inconsistent checks scattered across the entire codebase. A value that has been parsed once never has to prove its validity again, as long as it stays within the same, immutable data flow.


import { z } from "zod";

const rawConfigSchema = z.object({
  apiUrl: z.string().url(),
  maxRetries: z.number().int().min(0).max(10),
  timeoutMs: z.number().positive(),
});

type AppConfig = z.infer<typeof rawConfigSchema>;

// Parse once, at the boundary where the environment provides raw data
function loadConfig(rawEnv: unknown): AppConfig {
  return rawConfigSchema.parse(rawEnv);
}

// Every function below trusts AppConfig completely, no re-checking needed
function createHttpClient(config: AppConfig) {
  return {
    baseUrl: config.apiUrl,
    retries: config.maxRetries,
    timeout: config.timeoutMs,
  };
}

const config = loadConfig(JSON.parse(process.env.APP_CONFIG ?? "{}"));
const client = createHttpClient(config);

6. Nested parsers for composed domain objects

In more complex domain models, parsers are often composed out of smaller parsers, a pattern that directly mirrors the composition of Zod schemas. An orderSchema combines an addressSchema and a lineItemSchema, each sub schema independently parses its own slice of the input data and throws with a precise path on failure, indicating exactly which nested field was invalid.

This compositional nature is one of the practical advantages of the principle over a single, monolithic validation function attempting to check a complex object in one step. Smaller, independently testable parsers can be reused deliberately, for example the same addressSchema for both shipping and billing addresses, without duplicating the checking logic.


import { z } from "zod";

// Small, composable parsers, each responsible for one part of the domain
const addressSchema = z.object({
  street: z.string().min(3),
  postalCode: z.string().regex(/^\d{5}$/),
  city: z.string().min(2),
});

const lineItemSchema = z.object({
  sku: z.string(),
  quantity: z.number().int().positive(),
  unitPrice: z.number().positive(),
});

// Composed parser: reuses the smaller parsers without duplicating logic
const orderSchema = z.object({
  shippingAddress: addressSchema,
  billingAddress: addressSchema,
  items: z.array(lineItemSchema).min(1),
});

type Order = z.infer<typeof orderSchema>;

function parseOrder(input: unknown): Order {
  // A failing nested field produces a precise path, e.g. "shippingAddress.postalCode"
  return orderSchema.parse(input);
}

7. Making illegal states unrepresentable

A further reaching goal of parse, don't validate is to shape a parser's target type so that invalid field combinations cannot even be represented, instead of catching them at runtime. One example: instead of an object with an optional field errorMessage: string | null alongside a status: "success" | "error", which theoretically allows the contradictory combination status: "success" with a set errorMessage, a discriminated union models these cases exclusively.

A value parsed through a discriminated union can no longer enter this contradictory state at all, the parser structurally guarantees that only meaningful combinations arise. This idea of making illegal states unrepresentable is the logical extension of the core principle: not just individual values get refined, the entire structure of a domain object is shaped so that faulty combinations are structurally ruled out.

8. Limits of the principle in practical application

Applying parse, don't validate everywhere without exception can lead to an explosion of small, specific types if every trivial condition gets turned into its own refinement type. For many simple cases, such as a number that merely needs to be positive without that property being semantically relevant elsewhere in the code, plain validation is often sufficient, without needing a dedicated branded type.

A sensible rule of thumb is to apply the principle deliberately at genuine system boundaries, request bodies, configuration files, external API responses, and to stay pragmatic with simple validation for purely internal, short lived intermediate values. The greatest value of the principle unfolds where a data value crosses multiple function boundaries, not for a single, locally scoped check.

9. Validating vs. parsing compared directly

The following table contrasts both approaches along the criteria that most often decide the choice in practice.

Criterion Validating (boolean) Parsing (refined type)
Return value boolean, knowledge is lost New, precise type as proof
Repeated checking Needed at every further use site Once at the boundary, guaranteed afterwards
Error localization Usually just yes/no, little context Precise path for nested structures
Illegal states Remain structurally representable Can be excluded via the target type
Best suited for Trivial, locally scoped conditions Values crossing function boundaries

Both approaches have their place, but once a value leaves its originating function, a parsed, refined type is the more robust foundation for the rest of the code.

Mironsoft

Parse, don't validate architecture, refinement types and domain modeling in TypeScript

Remove redundant checks from your codebase?

We analyze where your application validates the same data multiple times, introduce parsing at the real system boundaries, and model domain objects so that illegal states become unrepresentable.

Architecture review

Identifying redundant validation along the data flow

Refinement types

Introducing refined types for critical domain objects

Domain modeling

Discriminated unions against illegal state combinations

10. Summary

Parse, don't validate changes the fundamental question a codebase asks about untrusted data, from a fleeting "is this value valid right now" to a lasting "this value is proven valid for as long as its type exists". Refinement types make this insight visible in the type system, so subsequent functions can fully trust an already parsed input without repeating the same check.

Nested, compositional parsers scale this principle up to complex domain objects, while discriminated unions can structurally rule out illegal state combinations instead of catching them at runtime. The greatest practical benefit arises wherever a value genuinely crosses function boundaries, for purely local, trivial checks, plain validation remains the more pragmatic choice.

Parse, Don't Validate, the key points at a glance

Core difference

Validating returns boolean, parsing produces a new, more precise type.

Once at the boundary

Parse as early as possible, the guarantee applies everywhere afterwards.

Composition

Small parsers combine into complex domain objects.

Ruling out illegal states

Discriminated unions structurally prevent contradictory field combinations.

11. FAQ: Parse, Don't Validate

1What does parse, don't validate mean?
A parser transforms the value into a new, more precise type that itself proves the condition was met, instead of discarding the result.
2Why does validation discard gained knowledge?
A boolean does not change the type. Subsequent functions cannot rely on the check and must re-check or blindly trust it.
3What is a refinement type?
A type only produced through controlled parsing, whose existence guarantees a condition was met, such as ValidatedEmail instead of string.
4Difference from a simple branded type?
Branded type distinguishes nominally. Refinement type additionally proves a substantive condition was checked and met.
5Where in the code should parsing happen?
As early as possible, at the boundary to the outside world, such as a request body or configuration file. The entire call graph benefits afterwards.
6How do composed parsers work?
Smaller sub parsers combine into larger domain objects, each returns a precise path to the invalid field on failure.
7Making illegal states unrepresentable?
The target type is shaped so contradictory combinations cannot be represented, for example with a discriminated union.
8Is the principle tied to a library?
No, a general architecture principle. Zod's parse() is one example, hand written functions implement it just as well.
9When is plain validation still worthwhile?
For trivial, locally scoped conditions without relevance beyond the current function.
10Does this lead to too many small types?
Yes, if applied without reflection. Rule of thumb: apply deliberately at real system boundaries, otherwise use plain validation.