← Back to Blog
TypeScript

Type-Driven Development: the TDD no one talks about

Branded types, illegal states unrepresentable, parse don't validate: how to get the most out of the compiler by combining Type-Driven and Test-Driven Development.

📅 ✍️ Antoine Coulon
type-driven-developmentbranded-typessum-typesparse-dont-validatetypescript

We all know Test-Driven Development. Yet there’s another TDD, just as important and effective, that gets talked about far less: Type-Driven Development. The idea is simple: extract as much value as possible from your favorite statically typed language’s compiler to rule out a large class of problems before the code ever runs.

If you work with a statically typed language like TypeScript, C#, Scala, or Kotlin, then Type-Driven Development is already within reach. A few years ago, the JavaScript vs TypeScript debate was still raging. Today, TypeScript has established itself as an obvious standard, and that’s excellent news. But beyond the basic typing that lets you avoid the usual bugs, there are advanced concepts that push the use of the type system to another level. Here are three patterns you should start leveraging right now.

Branded Types: distinguishing what primitives conflate

Branded Types (and Value Objects more broadly) let you represent concepts that the compiler is able to tell apart, even when the underlying primitives are identical.

Take a concrete case. A UserId and a StoreId are both, technically, strings. Nothing stops you from accidentally swapping them in a function call, and the compiler won’t say a word, since as far as it’s concerned they’re just two strings. This is exactly the kind of silent error that ends up as a production bug.

The trick is to “brand” each type with a unique tag at the type level, which emulates nominal typing in a structurally typed language like TypeScript. From then on, UserId and StoreId are no longer interchangeable in the compiler’s eyes.

/**
 * BRANDED TYPES
 * https://effect.website/docs/code-style/branded-types/
 */

const storeId = "ada6967e-1f85-4363-816f-9f2b7f92b251";
const userId = "fddbe760-9a8e-4ef2-834e-fa0b0220fc83";

function registerInStore(userId: string, storeId: string) {}

// ❌ Both are strings, so easily interchangeable
registerInStore(storeId, userId);

import { Brand } from "effect";

// ✅ We use Branded Types to let TypeScript tell them apart at the
//    type level, emulating nominal typing
type StoreId = string & Brand.Brand<"StoreId">;
const StoreId = Brand.nominal<StoreId>();

type UserId = string & Brand.Brand<"UserId">;
const UserId = Brand.nominal<UserId>();

function safeRegisterInStore(userId: UserId, storeId: StoreId) {}

// ✅ Now they can no longer be swapped because they are
//    different in TypeScript's eyes
safeRegisterInStore(StoreId(storeId), UserId(userId));
//                  ^^^^^^^^^^^^^^^^
// Argument of type 'StoreId' is not assignable to parameter of type 'UserId'.

The accidental argument swap, which went unnoticed with plain strings, becomes a compile error. The bug no longer even has a chance to exist.

Making illegal states unrepresentable

The second pattern is about ensuring that types reflect states that should be mutually exclusive rather than additive. For this we rely on Sum Types (union types in TypeScript, sealed traits elsewhere).

The canonical example is a bill (Bill). A bill can be “paid”, in which case the payment data (date, amount paid) is available. Or it’s “unpaid”, and then only the amount still due makes sense. These two configurations must never coexist.

/**
 * MAKE ILLEGAL STATES UNREPRESENTABLE
 * https://fsharpforfunandprofit.com/posts/designing-with-types-making-illegal-states-unrepresentable/
 */

interface PaidBill {
  type: "paid";
  paidAt: Date;
  amountPaid: number;
}

type UnpaidBill = {
  type: "unpaid";
  amountDue: number;
};

type Bill = PaidBill | UnpaidBill;

function payBill(bill: UnpaidBill): PaidBill {
  // ✅ A Bill can only be paid if it hasn't been paid yet
  return { type: "paid", paidAt: new Date(), amountPaid: bill.amountDue };
}

function generateInvoice(bill: PaidBill): string {
  // ✅ An invoice can only be generated for a paid Bill
  return `
    Invoice for a paid bill ${bill.amountPaid} on ${bill.paidAt.toISOString()}
    Thank you for your payment!
    Your bill is now settled.
  `;
}

With this modeling, payBill only accepts an UnpaidBill and generateInvoice only accepts a PaidBill. The compiler guarantees you’ll never try to pay an already-settled bill, nor invoice an unpaid one. The business rule is encoded in the types themselves.

By contrast, here’s the anti-pattern: a “catch-all” type where every field is optional and the status is just a string. Nothing then stops you from building inconsistent objects, and every function has to defensively check the state at runtime.

// ❌ Doesn't let you model illegal states at the type level
type AmbiguousBill = {
  status: "paid" | "unpaid";
  amountDue?: number;
  amountPaid?: number;
  paidAt?: Date;
};

const nonsense: AmbiguousBill = {
  status: "paid",
  amountDue: 1000, // ❌ inconsistent with "paid" = illegal state
};

function ambiguousPayBill(bill: AmbiguousBill): AmbiguousBill {
  if (bill.status === "unpaid") {
    return { status: "paid", amountPaid: bill.amountDue };
  } else {
    // ❌ Illegal state: you can't pay an already-paid bill
    throw new Error("Cannot pay a bill that is already paid.");
  }
}

function ambiguousGenerateInvoice(bill: AmbiguousBill) {
  // ❌ Forced to check the bill's state to generate an invoice
  if (bill.status === "paid" && bill.amountPaid && bill.paidAt) {
    return `
      Invoice for a paid bill ${bill.amountPaid} on ${bill.paidAt.toISOString()}
      Thank you for your payment!
      Your bill is now settled.
    `;
  }
  // ❌ Illegal state: you can't generate an invoice for an unpaid bill
  return "illegal_state";
}

The difference is striking. In the ambiguous version, illegal states are representable: so you have to pile on runtime guards, throw exceptions, and invent “impossible” branches that return an "illegal_state". In the correctly typed version, those cases simply don’t exist: the compiler eliminated them at design time.

Parse, don’t validate

The third pattern starts from an observation: when you’ve just performed a runtime validation on an object or a property, that information should be expressed and preserved at the type level. Otherwise, you throw it away the moment you acquire it.

The clearest example is a non-empty array. A function that checks an array isn’t empty but returns a plain Array loses the information: on the very next line, the compiler still doesn’t know the array contains at least one element. Returning a NonEmptyArray, on the other hand, encodes the check in the type, and the rest of the program can benefit from it.

/**
 * PARSE, DON'T VALIDATE
 * https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/
 */
const someArray = [1, 2, 3, 4, 5];

type NonEmptyArray<T> = [T, ...Array<T>];

function toNonEmptyArray<T>(array: T[]): NonEmptyArray<T> {
  if (array.length === 0) {
    throw new Error("Array cannot be empty");
  }

  return array as NonEmptyArray<T>;
}

function main() {
  // ❌ Validation: the information coming out of validation is
  //    not reflected in the type system
  if (someArray.length > 0) {
    const item = someArray[0];
    //    ^ number | undefined
  }

  // ✅ Parsing: the information is encoded in the type system
  // 👉 Requires { "noUncheckedIndexedAccess": true } in
  //    the tsconfig.json
  const array = toNonEmptyArray([1, 2, 3]);
  const item = array[0];
  //    ^ number
}

The nuance is subtle but decisive. In the “validation” branch, even after checking someArray.length > 0, the access someArray[0] stays typed number | undefined: the check taught the compiler nothing. In the “parsing” branch, toNonEmptyArray transforms the data into a type that carries the non-emptiness guarantee; the access array[0] is then typed number, without undefined. (Note: for indexing to be correctly typed as potentially undefined in the first place, the noUncheckedIndexedAccess option must be enabled in the tsconfig.json.)

Parsing rather than validating means never letting information hard-won at runtime evaporate before the next type-check.

The winning duo: Type-Driven + Test-Driven Development

In his post “Type Wars,” Robert C. Martin (Uncle Bob) argued that Test-Driven Development paired with a dynamically typed language would eventually replace statically typed languages. My view is different: the two approaches don’t oppose each other, they complement each other, and we have everything to gain from combining them.

Using both TDDs in symbiosis is extremely powerful, because it’s like having two real-time copilots showing you the way:

Where Test-Driven Development validates what the code does, Type-Driven Development restricts up front what the code can do. Illegal states disappear at design time; the tests can then focus on the genuinely interesting business logic, without wasting their energy covering cases the types have already made impossible.

Combining the two is the best way to get an extremely sharp feedback loop: more productivity, more efficiency, and notably more robust software.

Conclusion

Type-Driven Development isn’t an alternative to Test-Driven Development: it’s its natural complement. Branded Types, unrepresentable illegal states, and “parse, don’t validate” are three concrete levers for turning the compiler into an active ally rather than a mere syntax checker. Each one moves a category of errors from runtime to compile-time, where they cost the least to fix.

The investment in the type system pays itself back at every refactoring, at every new feature, at every team member who discovers the code. So next time you write : string a little too quickly, ask yourself: is it really just any string, or a concept the compiler could keep track of for you?