HiveCore Dev logo hivecore.dev

Type-Driven Development: A 30-Minute Primer

// essay · HiveCore Dev · 2026-05-09

TL;DR: Type-driven development means making invalid states unrepresentable. The types refuse to compile when you do something wrong. The result: less code, fewer tests, more confidence.

The core idea

Most type systems describe data after it exists. Type-driven development uses the type system to design the data so that invalid states can't exist in the first place.

Example: instead of { status: string; error?: string; data?: T }, use a discriminated union. The compiler refuses to let you check data when status === "error".

// before — invalid states representable
type Response<T> = {
  status: "success" | "error" | "loading";
  data?: T;
  error?: string;
};

// after — invalid states unrepresentable
type Response<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

Newtype the primitives

Strings are too permissive. A user ID and an email are both strings, but you should never pass one where the other is expected. Wrap them in branded types.

type Brand<T, B> = T & { __brand: B };
type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;

function makeUserId(s: string): UserId { return s as UserId; }
function makeEmail(s: string): Email {
  if (!/^.+@.+\..+$/.test(s)) throw new Error("bad email");
  return s as Email;
}

function sendEmail(to: Email, body: string) { /* ... */ }

const id = makeUserId("u_123");
// sendEmail(id, "hi");        // type error — id is UserId, not Email
sendEmail(makeEmail("a@b.co"), "hi");

Parse, don't validate

Validation returns a boolean. Parsing returns a typed value or fails. Always parse: every check should give the type system new information.

type ParsedDate = Brand<Date, "ParsedDate">;

function parseDate(s: string): ParsedDate | null {
  const d = new Date(s);
  if (Number.isNaN(d.getTime())) return null;
  return d as ParsedDate;
}

const d = parseDate(req.query.from);
if (!d) return res.status(400).send("bad date");
// from here on, d is ParsedDate — no more rechecks

Tests you no longer need

When invalid states are unrepresentable, you stop writing tests for them. You don't need a test for 'what if status is error and data is set' — that combination doesn't compile.

This is the real win of type-driven development. Not 'fewer bugs' — fewer tests, with the same confidence.

Where it's overkill

Scripts. One-off data wrangling. Code with a lifespan under a week. The cost-benefit only pays off when the code lives long enough for the type-driven design to save you a refactor or a 3am debugging session.

Related reading