Skip to content
Dev Tools Article

Structural Typing vs. Domain Integrity: Parsing in TypeScript

Stop validating strings repeatedly. Use nominal type branding to enforce domain invariants directly in the TypeScript compiler.

Lenn Voss
Lenn Voss
Cloud & Infrastructure Writer · Jun 30, 2026 · 5 min read
Structural Typing vs. Domain Integrity: Parsing in TypeScript

We have all worked in codebases that suffer from defensive programming. It starts innocently: a user registration form, an API endpoint, or a database query. Over time, the code accumulates checks like barnacles. You see if (user.email) or if (isValidEmail(email)) scattered across every layer of the application.

This anti-pattern is what Alexis King famously termed "shotgun parsing" in her 2019 essay, "Parse, don't validate." The core thesis is simple: a validator checks an input, returns a boolean or throws an error, and immediately discards the proof of validity. The rest of your program has to take it on faith that the check happened. A parser, on the other hand, takes unstructured input and returns a new, more precise type that preserves the proof of validity. Once a string is parsed into an EmailAddress, the compiler guarantees that any function receiving it is dealing with a valid email. No re-checking required.

In languages with nominal type systems like Haskell, Elm, or F#, this is the default way to write code. But in TypeScript, the language actively resists this pattern. Because TypeScript is structurally typed, two types with the same shape are identical. If you define type Email = string, the compiler treats it as a mere alias. You can pass any arbitrary string to a function expecting an Email, and the compiler will not blink.

To write secure, predictable software, we have to force nominal typing onto a structural system.

The Structural Roadblock

Consider the standard validation workflow. You define an interface, run a validation function, and then pass the data deeper into your call stack:

interface User {
  id: number;
  email: string;
  age: number;
}

function isValidUser(user: User): boolean {
  if (!user.email.includes("@")) return false;
  if (user.age < 0 || user.age > 150) return false;
  return true;
}

function sendWelcome(user: User) {
  if (!isValidUser(user)) {
    throw new Error("Invalid user");
  }
  // ...three function calls later
  emailService.send(user.email, `Welcome!`);
}

This code compiles, but it contains a fundamental design flaw. The type of user.email is string. The type of user.age is number. The moment isValidUser returns, the type system forgets that any validation occurred. If a developer later refactors emailService.send to accept any string, there is nothing stopping them from accidentally passing an unvalidated raw string.

To fix this, we need a way to tell the compiler that a string is not just a string, but a specific, validated domain type.

Forging Nominal Types via Branding

The standard pattern for simulating nominal types in TypeScript is type branding (sometimes called tagging). By intersecting a primitive type with an object type containing a unique literal or symbol, we create a type that cannot be implicitly created from a raw primitive.

While some developers use string literals for branding, using unexported unique symbols is far more secure. It prevents external code from forging the brand:

declare const EmailBrand: unique symbol;
declare const AgeBrand: unique symbol;

export type Email = string & { readonly [EmailBrand]: true };
export type Age = number & { readonly [AgeBrand]: true };

Because the symbols EmailBrand and AgeBrand are not exported from the module where they are declared, no code outside of this module can construct an object that satisfies the intersection.

This relationship is one-way. An Email is still assignable to a string because it contains all the properties of a string. This means you can use standard string methods on an Email without casting. However, a raw string is not assignable to an Email. To get an Email, you must go through a parser.

Designing the Parser Boundary

The parser is the trusted boundary of your module. It is the one place where we are allowed to use type assertions to "lie" to the compiler, converting a raw primitive into a branded type after verifying our domain invariants.

Instead of throwing exceptions, a robust parser should return an explicit result type representing success or failure:

type ParseError = { kind: "ParseError"; message: string };
type Parsed<T> = { kind: "ok"; value: T } | { kind: "err"; error: ParseError };

export function parseEmail(raw: string): Parsed<Email> {
  if (!raw.includes("@")) {
    return {
      kind: "err",
      error: { kind: "ParseError", message: "Missing @ character" }
    };
  }
  return { kind: "ok", value: raw as Email };
}

export function parseAge(raw: unknown): Parsed<Age> {
  if (
    typeof raw !== "number" ||
    !Number.isInteger(raw) ||
    raw < 0 ||
    raw > 150
  ) {
    return {
      kind: "err",
      error: { kind: "ParseError", message: "Age must be an integer between 0 and 150" }
    };
  }
  return { kind: "ok", value: raw as Age };
}

The type assertion raw as Email is the critical step. We are telling the compiler: "We have verified the invariants. You can now trust that this string is an Email."

Now we can define our domain models using these branded types:

interface ValidUser {
  readonly email: Email;
  readonly age: Age;
}

Any function that accepts a ValidUser can now execute its logic with absolute certainty. The compiler guarantees that the email has been validated, eliminating the need for defensive checks deeper in the call stack.

Practical Trade-offs in Production

While branded types offer compile-time guarantees, they are not a silver bullet. Adopting this pattern requires balancing type safety against developer experience and system complexity.

1. Serialization and Deserialization Boundaries

Branded types only exist at compile time. When you serialize a ValidUser to JSON via JSON.stringify(), the brands disappear. When you parse it back using JSON.parse(), you get raw strings and numbers. This means you must re-parse data at every network and storage boundary.

2. Boilerplate and Friction

Branding every primitive in a codebase is a recipe for developer fatigue. It is best to reserve branding for high-value domain concepts with strict invariants, such as email addresses, IDs, currency amounts, or status codes. Branding a simple description field that has no validation rules adds unnecessary friction.

3. Ecosystem Compatibility

Many third-party libraries, validation engines, and ORMs do not understand branded types out of the box. You may need to write thin wrapper functions or custom mappers to bridge the gap between your branded domain model and external libraries.

Shifting the Cognitive Load

Moving from validation to parsing shifts the cognitive load of maintaining system invariants from the developer's memory to the compiler. Instead of writing unit tests to ensure that every function calls isValidEmail, you write a single parser and let TypeScript enforce the contract across your entire codebase.

It requires writing slightly more code at the boundaries of your application, but the payoff is a core domain layer that is clean, self-documenting, and mathematically impossible to put into an invalid state.

Sources & further reading

  1. Parse, Don't Validate – In a Language That Doesn't Want You To — cekrem.github.io
Lenn Voss
Written by
Lenn Voss · Cloud & Infrastructure Writer

Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.

Discussion 1

Join the discussion

Sign in or create an account to comment and vote.

Theo Kallis @testing_theo · 5 hours ago

all this talk about parsing and validation, but where are the tests to ensure these domain invariants hold up over time and refactorings?

Related Reading