Type-Driven Application Architecture

At file scale, types catch typos. At system scale, they encode decisions: which layer may talk to which, where validation happens, and which invariants the compiler now enforces for free. This final production lesson ties the track together — every technique from phases 2 and 3 becomes an architectural tool.

Three horizontal layers — frameworks, domain, boundaries — with domain types flowing inward and framework types never leaking past their layer
Figure 1 — Layered types. Domain types (User, Order) are the currency of the inner layers; framework types (Request, React props) stop at their edge. Arrows point inward only — the domain imports nothing.

Types are architecture

Architecture diagrams show boxes and arrows; TypeScript types make those arrows checked. A rule like "the UI layer never touches the database" is a comment — until the types enforce it, at which point it becomes a compile error. This lesson is about choosing types whose shape is the architecture.

Domain types versus framework types

The most damaging habit in typed codebases is letting framework types leak inward: a React component passing AxiosResponse to a service that hands a Request to a database row. Each layer then breaks when any other layer changes. The fix is a dedicated domain vocabulary:

Purpose of the example: show a domain type that no framework can leak into, and the conversion points that guard it.

// domain/user.ts — imports NOTHING from frameworks, db, or http
export interface User {
  readonly id: UserId;          // branded ID — see below
  readonly email: string;       // validated at the boundary, trusted inside
  readonly displayName: string;
}

// The rest of the app speaks only in this vocabulary:
// services, components, and tests all import User — not Request, not Row.

What you should see: a file with zero dependency imports. That is the test for a domain type: if it imports from a framework, it belongs to the framework's layer, not the domain's.

Parse, don't validate — at the boundary

Where do types come from at runtime? From the boundary — HTTP handlers, form submits, config files — where everything is unknown. The architecture rule: parse external data into trusted types once, at the edge; never re-validate in the interior.

Purpose of the example: one boundary, one parser, and a type that the compiler treats as trustworthy everywhere else.

// boundaries/user-schema.ts — the ONLY place User is constructed from outside
import { z } from "zod";
import type { User } from "../domain/user.js";

// The schema is the runtime truth; the type is its compile-time mirror.
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  displayName: z.string().min(1),
});

// Parse at the boundary: unknown goes in, a trusted User comes out.
export function parseUser(input: unknown): User {
  return UserSchema.parse(input) as User;  // throws on invalid data — loudly, once
}

// Every HTTP handler does the same two lines:
// const user = parseUser(await request.json());

What you should see: invalid input throws at the boundary with a precise message; valid input flows inward as User, and no interior code ever checks typeof email === "string" again. The schema-and-type pair is written once — with z.infer you can even derive the type from the schema and delete the duplicate declaration.

What we learn: validation done this way is not defensive clutter; it is the load-bearing wall of the architecture. Interior code stays clean because the boundary is strict.

Branded types: making illegal states unrepresentable

Two domain types can share every field and still be incompatible — that is the point. A UserId and an OrderId are both strings, but passing one for the other is a bug that string typing happily accepts. Branded types close the loophole using intersections (phase 2) plus a phantom tag:

Purpose of the example: distinct IDs at zero runtime cost, by teaching the compiler something the runtime cannot express.

// domain/branded.ts
type Brand = T & { readonly __brand: TBrand };

export type UserId  = Brand;
export type OrderId = Brand;

// The ONLY public constructor — the choke point where validation lives:
export const userId = (raw: string): UserId => {
  if (!raw.startsWith("usr_")) throw new Error(`bad UserId: ${raw}`);
  return raw as UserId;          // assertion is safe: we just validated
};

// ShipOrder demands the right ID — swapping them no longer compiles:
function shipOrder(id: OrderId): void { /* … */ }

const u = userId("usr_42");
shipOrder(u);        // ❌ error TS2345: UserId is not assignable to OrderId
shipOrder("ord_99"); // ❌ raw strings are not OrderId either — by design

What you should see: two compile errors, both catching real-world mix-ups. The __brand property exists only in the type system — erasure removes it — so there is no runtime overhead. The single constructor is also the single place validation lives, mirroring the boundary rule above.

Dependency direction, checked by imports

Clean-architecture layering is enforced by nothing in untyped JavaScript; in TypeScript you can police it with a lint rule over imports (or, in bigger systems, project references). The checkable rule set:

domain/      imports: nothing project-internal
application/ imports: domain/
adapters/    imports: domain/, application/
entrypoints/ imports: all of the above
// Any import that points inward-from-outward is a lint error in CI.

What you should see: a PR that reaches from domain/ into adapters/ fails CI before review. The type system cannot check layer semantics directly, but imports are data, and data can be checked — this is the same philosophy as the boundaries lesson, applied to the module graph (see the modules lesson).

Practice: design, then break, then fix

Extract a domain type

Pick any project where an interface Request or a database row object flows through business logic. Extract a domain type, add one branded ID, and route all construction through one parsing function. What you should see: the diff deletes typeof checks scattered across the interior — proof the architecture took over a job the code was doing manually.

Test the invariants, not the types

Write unit tests for the constructors (bad input throws) rather than the consumers (good input flows). What you should see: a small, stable test suite that protects the system's vocabulary — the highest-value tests in a type-driven codebase. With this, every phase-4 skill is in place; the next phase puts it to work on real projects.