Hyperlinkv0.8.0-beta.28

Principles

How we write code here, and how we shape the system. Every concrete rule downstream enforces one of these — when a rule and a principle conflict, the principle wins and the rule is wrong. The mechanical chapters state the how; this chapter is the why they point back to. It runs from the most general stance to the most specific technique.

Composition over inheritance

srcexamples

Behavior is built by composing small values, layers, and combinators — never by class hierarchies for logic reuse. The only class extends in the codebase is the Service / Tag factory form (a construction shape, not an OO hierarchy); nothing subclasses to inherit behavior. Need a variation? Compose a combinator or provide a different layer — never reach for a base class.

// ❌ bad — subclass to add behavior
class LoggingStore extends MemoryStore { /* override append… */ }

// ✅ good — compose the behavior onto the value
const store = memoryStore.pipe(Store.mapEffects(withLogging))

Single source of truth

srcexamplestestdocs

Each fact lives in exactly one place; everything else derives from it. Never store the same truth twice — the copy always drifts. The Tag is the one home for a wire schema; the dead-letter budget is read from attempts; the worker outcome is recorded once. If youre about to write a fact down a second time, derive it instead.

// ❌ bad — `remaining` is a second copy of the same fact; it will drift
const entry = { attempts, remaining: maxAttempts - attempts }

// ✅ good — one fact stored, the rest derived
const remaining = (e: Entry) => maxAttempts - e.attempts

Dont fight the framework

srcexamples

Compose with Effect, never around it. Behavior is added as post-construction combinators (Hyperlink.withReadiness), not baked constructor options or plugin arrays. Dependencies flow through Layer.provide — a Layer is never passed as config data. When the framework already has a shape for something, use that shape.

// ❌ bad — a Layer smuggled in as data
make({ dependencies: [DbLayer] })

// ✅ good — provided through the layer graph
make(config).pipe(Effect.provide(DbLayer))

Lean into functional Effect

srcexamples

Use Effects vocabulary instead of re-implementing control flow: map / flatMap / zip / forEach / catch* over hand-rolled loops and try/catch. Let the effect channel carry errors and requirements; let immutability be the default. The framework already solved control flow — spend your effort on the domain.

// ❌ bad — manual loop + accumulator
const out = []
for (const id of ids) out.push(yield* fetch(id))

// ✅ good — one combinator
const out = yield* Effect.forEach(ids, fetch)

Dont reinvent, dont pre-abstract

srcexamples

Reuse what exists before building; build one concrete thing before generalizing. Metrics are standard Effect Metric; retention and alerting are OTEL/Grafanas job — we dont rebuild them. A new capability reuses an existing seam rather than adding a parallel alias. And a shared helper is extracted only once the shape has provably repeated — one hand-built instance first, abstraction second.

Effects are descriptions — no eager side effects

srcexamples

An Effect is a description of work, run at the edge of the program — not something that fires when constructed. No raw async / Promise, no side effects in constructors, and no ambient globals: time comes from Clock, randomness from Random, HTTP from HttpClient. (This is why the lint bans Date.now, Math.random, and fetch.) Build the description; run it once, at the top.

// ❌ bad — eager, ambient, unrepeatable
const now = Date.now()

// ✅ good — an effect, read from the Clock service
const now = yield* Clock.currentTimeMillis

Errors are values, not exceptions

srcexamples

Failure is modeled in the typed error channel with Data.TaggedError, never throw. A caller sees every way a call can fail from its signature and handles them explicitly. An exception is an escape from the type system; we dont take it.

// ❌ bad — throws; invisible to the caller's type
if (!valid) throw new Error("bad item")

// ✅ good — a typed failure in the E channel
class BadItem extends Data.TaggedError("BadItem")<{ id: string }> {}
return yield* Effect.fail(new BadItem({ id }))

Fail loudly, never silently

srcexamples

A wrong state errors — or dies — at the earliest point it can be detected, never gets papered over. A missing RPC handler fails the server at boot, not on first call. Misconfiguration blocks acquisition with a loud timeout, never a silent placeholder. A value that looks the same but behaves differently is banned — divergence must surface as a type or dependency error. Silence is the failure mode we design against.

// ❌ bad — a default silently masks missing config
const url = process.env.SERVICE_URL ?? "http://localhost"

// ✅ good — required config fails loudly at load
const url = yield* Config.string("SERVICE_URL")

Make illegal states unrepresentable

srcexamples

Encode invariants in types so bad combinations cant be constructed. Derive behavior from the shape of a type or config, not from runtime boolean flags bolted on beside it. If a state shouldnt exist, the type shouldnt permit it — validation you can delete because it cant happen.

// ❌ bad — two booleans encode four states, one of them nonsense (loading && error)
interface Conn { loading: boolean; error: boolean; data?: Data }

// ✅ good — a union with no illegal state
type Conn =
  | { _tag: "Loading" }
  | { _tag: "Ready"; data: Data }
  | { _tag: "Failed"; error: Error }

Pipe, dont wrap

srcexamples

Data flows top-to-bottom through .pipe(...), not inside-out through nested wrapping. The combinators are data-last so pipelines stay flat. If youre counting closing parens, it should have been a pipe.

// ❌ bad — inside-out, unreadable
Effect.map(Effect.flatMap(fetchUser(id), loadOrders), summarize)

// ✅ good — linear
fetchUser(id).pipe(Effect.flatMap(loadOrders), Effect.map(summarize))

Generators only when they earn it

srcexamples

.pipe is the default; Effect.gen is a tool for one job — sequential, interdependent steps that genuinely read clearer as imperative flow. Reaching for gen by habit, for a single map or a two-step chain, is wrong. Clarity justifies a generator; nothing else does.

// ❌ bad — a generator for a single map
Effect.gen(function* () {
  const u = yield* fetchUser(id)
  return u.name
})

// ✅ good — just pipe it
fetchUser(id).pipe(Effect.map((u) => u.name))

State lives in references, read through effects

srcexamples

Mutable state is a Ref / SubscriptionRef accessed through an effect — never a plain field a background fiber mutates. Reads are effects, writes are effects; the value is always observed consistently.

// ❌ bad — a plain field a fiber mutates behind readers' backs
class Counter { count = 0 }

// ✅ good — a Ref; reads and writes are effects
const count = yield* Ref.make(0)
yield* Ref.update(count, (n) => n + 1)

Derive from the contract

srcexamplestestdocs

Anything that mirrors the shape of the system is generated from the contract, never hand-maintained beside it. Dashboard widgets come from specOf + methodMeta; the rule manifest is derived from the {#id .severity} blocks in these very docs; a nodes readiness folds over its one registry of served resources. A hand-kept parallel list is drift waiting to happen — this is single-source-of- truth applied to structure.

// ❌ bad — a hand-kept list that forgets the next resource
const widgets = [QueueWidget, ProcessWidget]

// ✅ good — derived from the contract, so new resources appear automatically
const widgets = methodsOf(specOf(tag)).map(widgetFor)
Edit this page on GitHub