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.
Table of Contents
- 1. Parse, don't validate: understanding the core principle
- 2. Why plain validation discards knowledge already gained
- 3. Refinement types: anchoring information in the type itself
- 4. A parser as a function from unknown to a refined type
- 5. Parsing once at the boundary instead of re-checking everywhere
- 6. Nested parsers for composed domain objects
- 7. Making illegal states unrepresentable
- 8. Limits of the principle in practical application
- 9. Validating vs. parsing compared directly
- 10. Summary
- 11. FAQ
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.