ValidationLANGUAGE-SPECIFICGENERALSCALE-SPECIFIC

Parse, Do Not Validate

A check that returns a boolean throws away what it learned; a check that returns a typed value hands the knowledge to every line below it.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

Why does validation keep happening more than once, in more than one place, with slightly different rules?

The requirement

An email address is checked at signup. It is also checked at invite, at profile update, at import, and in the notification service that reads it back out of the database.

The obvious build

Write isValidEmail(s: string): boolean and call it wherever an email arrives. One function, one definition, reused everywhere.

Why it breaks

After the check, the value is still a string. A function four calls down cannot tell whether it was checked, so it checks again — or does not, and neither choice is visible at the call site.

How it breaks in production
  • After the check, the value is still a string. A function four calls down cannot tell whether it was checked, so it checks again — or does not, and neither choice is visible at the call site.
  • The re-checks drift. isValidEmail gains a rule about plus-addressing in one place and a length cap in another, and one path normalises to lowercase while the others do not.
  • The check and the use are separated by code, so if (isValidEmail(x)) { ... } protects one branch and the else branch, added later, uses x anyway.
  • The type tells you nothing: function invite(email: string, name: string) can be called with the arguments swapped and compiles.
  • The same shape recurs with worse consequences: getFirst(xs: T[]) needs a non-empty array, checks length > 0, and the callers that skipped the check get undefined at runtime with a type that says T.
  • Values that came from the database are the same string with no history at all, so the notification service re-derives the rules or trusts them (Every Input Surface).
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • A validator has type unknown -> boolean. It discards its own result: the caller knows more after calling it, but the type system does not, so nothing downstream can rely on it.
  • A parser has type unknown -> Result<T, E>. It returns the knowledge as a value whose type encodes what was established. Holding a Email means the check happened.
  • The consequence is structural: the check can only happen where the value is constructed, so "did anyone validate this?" becomes "where did this type come from?", which has one answer.
  • The second half is making illegal states unrepresentable: NonEmpty<T> instead of T[] plus a comment, PositiveInt instead of number plus a rule, a PaidOrder type that only the payment step can produce.
  • The mechanism in a language without dependent types is the smart constructor: a private/branded type with exactly one public way to make one. The guarantee is only as strong as the discipline of "one constructor".
  • Crucially, in TypeScript this is a compile-time guarantee only. Types are erased; JSON.parse(s) as Email produces a value the compiler treats as checked and the runtime knows nothing about. The runtime check must actually run (Transport Validation).
  • Every serialization boundary resets it. A Money written to a NUMERIC column comes back as a string or a float, and a Email sent as JSON arrives as a string (Deserialization: Bytes to Objects).

Where the knowledge goes

The two versions below do the same runtime work. The difference is entirely in what happens *after* the check: one leaves a string that looks exactly like every unchecked string in the process, and one leaves a value whose type is the record of the check.

Read the last line of each. In the first, sendInvite cannot know whether it was called from the checked branch — so it either trusts, or re-checks with a slightly different rule. In the second, the question cannot be asked, because there is no way to obtain an Email without going through Email.parse.

A boolean guard, and a parser
Validate: returns whether, and forgets
function isValidEmail(s: string): boolean {
  return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s) && s.length <= 254
}

function handleInvite(body: any) {
  if (!isValidEmail(body.email)) return badRequest('email')
  sendInvite(body.email, body.name)          // both are `string`; swap them, it compiles
}

function sendInvite(email: string, name: string) {
  // was `email` checked? cannot tell. so either trust it, or check again
  // with whatever rule this file happens to import
}
Parse: returns the knowledge
declare const brand: unique symbol
export type Email = string & { readonly [brand]: 'Email' }

export const Email = {
  parse(u: unknown): Result<Email, 'invalid_email'> {          // the ONLY constructor
    if (typeof u !== 'string' || u.length > 254) return err('invalid_email')
    const s = u.trim().toLowerCase()                            // normalisation, once
    return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s) ? ok(s as Email) : err('invalid_email')
  },
}

function handleInvite(body: unknown) {
  const cmd = InviteCommand.parse(body)                         // -> { email: Email, name: DisplayName }
  if (!cmd.ok) return badRequest(cmd.error)
  sendInvite(cmd.value.email, cmd.value.name)                   // swapping them is a type error
}

function sendInvite(email: Email, name: DisplayName) {
  // the parameter type IS the proof. nothing to re-check, nothing to trust.
}

The first version cannot answer "has this been checked?" anywhere below handleInvite, so the rule gets duplicated with drift or skipped silently — and because both parameters are string, transposing them is undetectable. The second makes the check a precondition of *obtaining the value at all*: there is exactly one construction site, so normalisation happens once, the rule exists once, and a swapped argument list stops compiling. The cost is real and specific: as Email inside parse is an unchecked cast, so the entire guarantee rests on that being the only one in the codebase.

Types that delete branches

The version of this idea with the clearest payoff is not about strings at all. It is about types whose shape removes a runtime check that would otherwise appear at every call site — and every removed check is a branch that cannot be forgotten.

The table is the practical guide: what you have, what the check costs when it is scattered, and what the parsed type buys. The right-hand column is the test of whether it is worth doing at all.

Raw typeThe invariant left implicitParsed typeWhat the type deletes
string emailChecked somewhere, maybe normalisedEmailRe-checks, and two definitions of valid
T[]"Callers must ensure it is non-empty"NonEmpty<T>head() returning T \| undefined at every call site
number amountCents or units? Positive?Money (integer minor units + currency)Float rounding, and mixed-currency addition
string idWhich entity is this the id of?UserId / OrgIdTransposed arguments that compile (Tenant Isolation)
DateUTC? Local? Whose local?Instant / LocalDateOff-by-one-day bugs at timezone boundaries
string HTMLEscaped, or attacker-controlled?SanitisedHtmlRendering unescaped input (XSS Defense by Output Context)
Order"Only if status is paid"PaidOrder (only the payment step returns it)A status check before every shipping operation

Every boundary hands you back an `unknown`

The honest limitation, and the one most often skipped when this idea is taught: a parsed type exists only inside the process, in memory, in this language. The moment a value is written to a socket, a column or a cache, the type is gone and what comes back is bytes.

This is not an argument against parsing — it is an argument for knowing where your boundaries are, because each one is a place where a cast is tempting and a re-parse is correct.

Where the type is lost, and what to do about it
  1. 1
    HTTP request body

    Arrives as bytes, becomes unknown after JSON.parse.

    fails by JSON.parse(b) as CreateUser — a cast, not a check. Nothing ran (Transport Validation).

  2. 2
    Database read

    Returns driver-typed values: NUMERIC as a string, TIMESTAMPTZ as a Date, JSONB as any.

    fails by Assuming a column matches the domain type because a migration once said so. Schema drift is silent.

  3. 3
    Cache hit

    Deserialised JSON with no class, no prototype, no brand.

    fails by Trusting cached shape across a deploy that changed the type (Cache-Aside).

  4. 4
    Queue payload

    A message enqueued by a possibly older version of the producer.

    fails by Assuming the producer is at the same version. During a rolling deploy it is not (Rolling Deployments).

  5. 5
    External API response

    JSON from a system you do not control or version.

    fails by Trusting the documented shape. Re-parse it exactly like user input (The Trust Boundary).

  6. 6
    Config / environment

    Strings, always. PORT is "3000", DEBUG is "false" which is truthy.

    fails by Coercing at use rather than parsing once at boot (Validate at Startup, Fail Loudly).

The rule that falls out: parse at every boundary, cast at none. A codebase can be audited for this by grepping for casts to domain types — and the count should be one per type, inside its constructor.

How to build it

Most important first.

  • Parse at the process edge and pass the parsed type inward. Nothing below the boundary should accept a raw request object or a bare string where a domain type exists (The Trust Boundary).
  • Return failures as values rather than exceptions where the caller is expected to handle them, so the type signature says the operation can fail (Reporting Validation Failures).
  • Give the type one constructor and make it the only way in. A branded type with a public cast is a comment.
  • Apply it where confusion is expensive: ids (UserId versus OrgId), money, time with a zone, and anything with a unit. Skip it where a plain string is genuinely a plain string.
  • Prefer types that remove a check over types that document one: NonEmpty<T> lets head() return T instead of T | undefined, which deletes a branch at every call site.
  • Re-parse at every deserialization boundary — database reads, queue payloads, cache hits, external responses — because the type did not travel with the bytes (Serialization: Objects to Bytes).
  • Derive the runtime schema and the static type from one declaration so they cannot disagree.

What can go wrong

Failure modes
  • A branded type with an exported as cast used "just this once" in a test helper, which then becomes the pattern.
  • Parsing that also normalises without saying so, so Email silently lowercases and a system that treats the local part as case-sensitive breaks.
  • Result types that callers ignore. In TypeScript an unchecked Result is a type error only if the code is written to make it one; in Go an ignored err compiles.
  • Types so fine-grained that the codebase is mostly conversion functions, and each conversion is a place to be wrong (What Serialization Costs).
  • A parsed type stored in a cache and read back as a plain object, so the invariant is gone and nothing says so (Cache-Aside).
  • Confusing parsing with authorization: a UserId is well-formed; it is not evidence that the caller may act on that user (Object-Level Authorization).
What can race
  • Parsing establishes a fact about a *value*, which cannot change — that is precisely why it does not race, and why it cannot express rules about the world.
  • A parsed OrderId proves the string is well-formed. It does not prove the order still exists; that is a check-then-act question with a gap (Business Validation, Database Constraints).
Security
  • The strongest security argument here is structural: with one constructor, "which code paths can produce an unvalidated value?" has a single, greppable answer (Parse, Validate, Authorize, Process).
  • Distinct types for distinct ids prevent a whole family of authorization bugs where a tenantId and a userId are both strings and get swapped in an argument list (Tenant Isolation).
  • Types that encode provenance — RawUserInput versus SanitisedHtml — make it a compile error to render the wrong one, which is more reliable than remembering to escape (XSS Defense by Output Context).
  • None of this holds at runtime in an erased-type language. A value cast rather than parsed is unchecked no matter what the signature says (Deserialization: Bytes to Objects).
Misreads
  • "This means never using booleans." isEmpty(list) is a fine boolean. The rule is about checks that establish an invariant the rest of the code needs to rely on.
  • "TypeScript types make it safe at runtime." They are erased. A branded type built by casting an unchecked value is a lie the compiler cannot detect (Deserialization: Bytes to Objects).
  • "Parsing is just validation with more types." The difference is where the knowledge goes. One discards it, one returns it — that is why one gets repeated and the other cannot.
  • "Apply it to every field." Applied uniformly it produces a codebase of conversion functions. Apply it where a mix-up is expensive: ids, money, units, provenance.
  • "Once parsed, always parsed." Only inside the process. Every serialization boundary reconstructs a plain value (Schema Leakage).

Operating it

How you see it in production
  • Count parse failures by type and field. Because parsing happens in one place per type, the counter is complete by construction.
  • Grep for casts to your branded types. That count is a direct measurement of how much the guarantee is worth; it should be one per type.
  • Watch for the same rule appearing in two files. Duplicated regexes are the observable symptom of the boolean-validator pattern (Backend Code Smells).
What changes at 10x and 100x
  • Runtime cost is one check where there were previously several. Parsing typically *reduces* total validation work by removing defensive re-checks.
  • On large payloads the mapping into domain types costs allocation, which can matter on a 50,000-row export and nowhere else (What Serialization Costs).
  • At team scale this is where the benefit is: a new engineer cannot accidentally pass an unchecked value into the domain, because the compiler will not let them (Transport, Application, Domain, Infrastructure).
What this costs
  • Ceremony. Every domain type needs a declaration, a constructor and conversions at the edges, and small services will feel this more than it repays.
  • Error handling becomes explicit and therefore verbose. That is the point and it is still more code than throw.
  • Boundaries multiply: the database, the cache, the queue and every external API all need a re-parse, and each one is a place someone will cast instead.
  • In a dynamically typed language the compiler gives you nothing, so the whole guarantee reduces to a runtime constructor plus convention.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • LANGUAGE-SPECIFICThe strength of the guarantee varies enormously. TypeScript can express branded types but erases them, so it is compile-time only and a cast defeats it silently. Rust and Haskell give a genuinely enforced newtype with a private constructor — the closest thing to a proof. Go has named types with real distinctness but no privacy within a package and no sum types, so the Result half is convention plus an err you can ignore. Python has no compile-time enforcement at all, so the equivalent is a runtime class with validation in __init__ (which is what Pydantic models are) and the discipline to construct rather than cast.
  • GENERALThe underlying idea — a check should return what it learned, not a boolean — is a design principle independent of type system, and it improves even untyped code by moving the check to one place.
  • SCALE-SPECIFICFlips on how far a value travels from where it is checked. In a 200-line service where parse and use are on adjacent lines, a boolean guard is perfectly clear and the ceremony buys nothing. Once a value crosses three modules and two teams, "was this checked?" stops being answerable by reading, and the type is the only mechanism that answers it without a convention everyone has to remember.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — newtypes, type erasure and what a type system can and cannot enforce once the program is running.