API & Architecture Patterns

Expert · 20 min read · ▶ live playground · ✦ checkpoint

API and architecture patterns in TypeScript are ways to make boundaries explicit: what callers may pass, what dependencies your code needs, and where untrusted outside details stop. The practical goal is the same as 27.1: keep your core domain small, typed, and hard to misuse.

This lesson is not an enterprise architecture tour. It is a toolkit for ordinary TypeScript: options objects for readable calls, builders for staged setup, brands for proved IDs, typed factories for dependency injection, and ports/adapters for seams.

Options objects make calls readable

An options object is one object parameter that bundles named settings for a function. It is better than a long row of booleans and strings because each value travels with a name:

type DeliveryMode = "preview" | "send";
 
type ReminderOptions = {
  to: string;
  subject: string;
  body: string;
  mode?: DeliveryMode;
  retryCount?: number;
};
 
function scheduleReminder(options: ReminderOptions): string {
  const mode = options.mode ?? "preview";
  const retryCount = options.retryCount ?? 0;
  return mode + " " + options.subject + " to " + options.to + " retries=" + retryCount;
}
 
console.log(scheduleReminder({
  to: "ada@example.com",
  subject: "Welcome",
  body: "Your account is ready",
  mode: "preview",
}));

→ preview Welcome to ada@example.com retries=0

The design rule from Designing a Library's Public API still applies: use unions for closed choices, overloads for a few distinct call shapes, and generics when caller-owned types flow through. An options object does not replace that decision; it packages the chosen public shape.

Do not make every option optional because it feels flexible. Required domain facts should stay required. Optional properties are for real defaults or genuinely independent features.

Fluent builders should encode the order

A builder is an API that constructs a value step by step. A fluent API is a builder style where each method returns the next object, so calls read in a chain like builder().to(...).subject(...).body(...).

Use this pattern when staged construction prevents mistakes. If a plain options object is clear enough, prefer the plain object.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ Welcome -> ada@example.com: Your account is ready

Two patterns are working together. The staged builder prevents a subject before a recipient. The branded EmailAddress prevents an arbitrary string from entering the message after proof has been required.

Branding is the cheap end of nominal typing: it distinguishes same-shaped values by meaning. But keep the Branded Types & Assertion Functions rule firm: brands are attached only after proof. makeEmailAddress checks the string, then uses the local assertion to record the fact the checker cannot discover by itself.

Inject dependencies with typed factories

Dependency injection means passing a function's outside needs in from the caller instead of constructing them inside. A database, mailer, clock, random id source, logger, or payment client is a dependency. You do not need a framework to use the pattern; a typed object parameter is enough.

A factory is a function that creates another object or set of functions. A typed factory can accept dependencies once, then return domain operations that are easy to test.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ sent to ada@example.com at 2026-07-05T09:00:00.000Z
→ messages: 1

UserStore, Mailer, and Clock are ports: small type contracts the domain core depends on. The in-memory users, recording mailer, and fixed clock are adapters: concrete objects that satisfy those ports.

That distinction is the seam. The service knows how to invite a user, return a Result, and require a real UserId. It does not know whether users came from a database, a file, a test fixture, or an API client. Real adapters still validate their boundaries with schemas/decoders before returning trusted domain values.

Keep the pattern budget small

These patterns have costs. Builders create extra names. Brands require constructors. Ports and adapters add indirection. Use them where the seam matters: public APIs, same-shaped domain values, slow or external dependencies, and code you want to test without real infrastructure.

Use the smallest pattern that tells the truth:

  • Options object for many named settings or future-compatible configuration.
  • Fluent builder when call order or staged setup matters.
  • Brand when a string or number has domain meaning after proof.
  • Typed factory when core logic needs outside dependencies.
  • Port/adapters when a boundary translates untrusted or replaceable systems into your domain.

Next lesson is the professional send-off: how to keep learning TypeScript, read release notes and design notes, review types well, and contribute without chasing hype.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion