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

Parse, don't validate: a practical guide for developers

Turn untrusted data into trusted types once.

Expona Admin7 min read

When untrusted data enters your system, you face a choice: validate it and hope it's safe, or parse it and guarantee it's correct. The difference between these two approaches shapes how error handling flows through your entire codebase. Parse, don't validate is the principle that transforms untyped input into a value your type system can trust, eliminating entire classes of bugs before they reach production.

Validation returns boolean — information lost

Most developers reach for validation first. You write a function that takes data and returns true or false. It feels simple, direct, and the pattern is everywhere in frameworks and libraries.

typescript

function isValidEmail(email: string): boolean {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(email);
}

function isValidAge(age: unknown): boolean {
  return typeof age === 'number' && age >= 18 && age <= 120;
}

// Usage
if (isValidEmail(userInput)) {
  // What is userInput here? Still string | null | undefined | object
  // You haven't transformed it
  saveUser(userInput);
}

The problem is what validation discards. When isValidEmail returns true, you know the email passed a regex test, but you haven't changed its type. userInput is still unknown, string, or whatever messy type it was before. Your type checker can't help you. Every function that receives this "validated" data must either assume it's correct (dangerous) or validate it again (wasteful).

Validation answers a yes-or-no question. Parsing answers the question and then transforms the data into a shape you can trust.

Parsing returns a typed value

Instead of asking "is this valid?", parsing asks "can I turn this into a value of the type I need?" If yes, you get that typed value. If no, you get an error. The return value itself becomes proof.

typescript

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

function parseEmail(input: unknown): Email {
  if (typeof input !== 'string') {
    throw new Error('Email must be a string');
  }
  
  const trimmed = input.trim().toLowerCase();
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  
  if (!regex.test(trimmed)) {
    throw new Error(`Invalid email format: ${trimmed}`);
  }
  
  return trimmed as Email;
}

// Usage
const email = parseEmail(userInput); // Type is Email now
// TypeScript knows email is an Email
// No second-guessing needed downstream
saveUser({ email }); // Type-safe

This uses a branded type pattern. The Email type has no runtime cost, but TypeScript treats it distinctly from a plain string. Once parseEmail succeeds, the value is statically guaranteed to be an email. You've moved the proof into the type system itself.

Parsing is stricter because the burden is on the parser to return exactly what downstream code needs. Validation was a pass-or-fail gate. Parsing is a transformation that either succeeds with a known type or fails with a reason.

Downstream code needs no checks

This is where parsing pays dividends. Once you've parsed data into a trusted type, every function that accepts that type can skip defensive checks. You don't need to validate again.

typescript

type Age = number & { readonly __brand: 'Age' };
type Email = string & { readonly __brand: 'Email' };

function parseAge(input: unknown): Age {
  if (typeof input !== 'number' || !Number.isInteger(input)) {
    throw new Error('Age must be an integer');
  }
  if (input < 18 || input > 120) {
    throw new Error('Age must be between 18 and 120');
  }
  return input as Age;
}

// Business logic functions accept the parsed types
function createUserAccount(email: Email, age: Age): void {
  // No need to check if email is valid
  // No need to check if age is in range
  // TypeScript and your brain both know these are safe
  
  console.log(`Creating account for ${email}, age ${age}`);
  // Send to database, API, whatever
}

// At the boundary, you parse once
try {
  const email = parseEmail(req.body.email);
  const age = parseAge(req.body.age);
  createUserAccount(email, age);
} catch (error) {
  res.status(400).json({ error: error.message });
}

Compare this to the validation approach. With validation, createUserAccount would receive string and number and have to distrust them. You'd add runtime checks inside business logic, mixing concerns and cluttering code.

Parsing pushes the burden to the boundary of your system. Once data crosses that boundary as a trusted type, the interior is cleaner, faster, and less error-prone.

Errors are handled in one place

When validation scatters throughout your codebase, error handling scatters too. One function returns false, another throws, another logs a warning. Your error messages are inconsistent.

Parsing centralizes error handling. Data enters your system at specific points—HTTP handlers, message consumers, file readers—and you parse it there. Error handling is colocated with parsing.

typescript

// A realistic scenario: parsing user registration input
interface RawUserInput {
  email?: unknown;
  age?: unknown;
  name?: unknown;
}

function parseUserInput(input: RawUserInput): { email: Email; age: Age; name: string } {
  const errors: string[] = [];
  
  let email: Email | null = null;
  let age: Age | null = null;
  let name: string | null = null;
  
  // Parse email
  try {
    email = parseEmail(input.email);
  } catch (err) {
    errors.push(`Email: ${err.message}`);
  }
  
  // Parse age
  try {
    age = parseAge(input.age);
  } catch (err) {
    errors.push(`Age: ${err.message}`);
  }
  
  // Parse name
  if (typeof input.name === 'string' && input.name.trim().length > 0) {
    name = input.name.trim();
  } else {
    errors.push('Name: must be a non-empty string');
  }
  
  if (errors.length > 0) {
    throw new Error(`Validation failed: ${errors.join('; ')}`);
  }
  
  return { email: email!, age: age!, name: name! };
}

// In your HTTP handler
app.post('/register', (req, res) => {
  try {
    const user = parseUserInput(req.body);
    // user is now fully typed and trusted
    createUserAccount(user.email, user.age);
    res.json({ success: true });
  } catch (error) {
    // One place to handle all parsing errors
    res.status(400).json({ error: error.message });
  }
});

This is powerful. Every parsing function throws a clear, structured error. The HTTP handler catches it and responds appropriately. No validation checks buried in business logic. No silent failures. No mixed metaphors of booleans and exceptions.

You can extend this further with Result types or Either types from functional programming, but even this simple approach is a massive improvement over scattered validation.

Common mistakes

Parsing too late. Some teams parse at the wrong layer—in the business logic instead of at the boundary. This defeats the purpose. Parse as soon as untrusted data enters your system.

Parsing without transformation. A parser that only validates and returns the same type isn't really parsing. A good parser normalizes input (trim, lowercase, format), checks invariants, and returns a new type. Even if it's the same underlying type, the branded type signals that it's been verified.

Over-engineering with types. You don't need a branded type for every value. Parse critical boundaries—user input, external APIs, file data. Internal function arguments that already come from parsed sources don't need extra wrapping.

Forgetting to fail fast. If parsing should fail, fail visibly and early. Don't return a nullable type and push the burden downstream. Throw an error or return a Result that forces the caller to handle it.

Key takeaways

  • Validation returns a boolean and loses information. Parsing transforms data into a type that proves correctness.
  • Use branded types to create a static guarantee that data has been parsed. TypeScript will enforce this at compile time.
  • Parse at system boundaries—HTTP handlers, message consumers, file readers—where untrusted data enters.
  • Once data is parsed into a trusted type, downstream business logic needs no defensive checks. Code is cleaner, faster, and less buggy.
  • Centralize error handling at the parsing layer. One place to handle all data entry errors, one place to respond to the caller.
  • Parsing is stricter than validation. It forces you to be explicit about what types your system accepts and what shapes it can work with.
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