Skip to content

Inject a defect helper; stop exposing the Defect marker

Problem

Defect is exported publicly and sits in the Result.* facade next to Result.Ok / Result.Err, implying it is a Result constructor. It is not: it returns the opaque qualify-time marker ({ [DEFECT]: true, cause }) a qualify function returns to triage a cause as unmodeled. Same name, different kind — a genuine source of confusion, and a facade-grouping wart (the facade is "grouped by what they return", yet Result.Defect returns a marker, not a Result).

A defect-state Result deliberately has no public constructor (defects arise at boundaries). That stays. The goal here is narrower: stop exposing the qualify-time marker as an importable value.

Decision

Inject the marker as a second argument to qualify instead of importing it.

ts
// before
fromPromise(fetchUser(id), (cause) =>
  cause instanceof NotFoundError ? ("not_found" as const) : Defect(cause),
);

// after
fromPromise(fetchUser(id), (cause, defect) =>
  cause instanceof NotFoundError ? ("not_found" as const) : defect(cause),
);

Rejected alternatives: throw-to-signal-defect (loses the concise ternary, overloads "throw in qualify", changes Thesis #3 more deeply) and keep-but- de-confuse (does not satisfy "not exposed at all").

Changes

Core

  • interop.tsfromThrowable / fromPromise qualify becomes (cause: unknown, defect: (cause: unknown) => Defect) => R. E = Exclude<R, Defect> is unchanged. qualifyToResult calls qualify(cause, defect), passing the module-level marker fn by reference (no per-call allocation). The isDefectMarker brand check stays — still how a returned marker is told apart from a modeled E. A throw inside qualify is still a Defect.
  • defect.ts — rename the marker fn Defectdefect (it is now an injected helper, not a constructor). Keep the DEFECT brand, the Defecttype, and isDefectMarker. All remain module-exported; none is re-exported publicly.
  • index.ts — delete export { Defect } from "./defect.js".
  • facade.ts — drop Result.Defect.
  • typedoc.json — add "Defect" to intentionallyNotExported (it is now referenced by a public signature but not exported).

Sibling packages

Both mint defects through the fromThrowable boundary, so they drop the Defect import and use the injected helper:

  • @unthrown/effect fromExit's replay → fromThrowable((): T => { throw … }, (_c, defect) => defect(…))().
  • @unthrown/standard-schema fromSchema(cause, defect) => defect(cause).

Tests

  • interop.spec.ts, async-result.spec.ts, types.test-d.ts — switch qualify callbacks to the two-arg form.
  • constructors.spec.ts — the marker unit test becomes "the injected defect helper mints a marker", driven through fromThrowable rather than importing the value.
  • The throw→defect defectOf / asyncDefect helpers (which fabricate a defect-state Result via a throw in a combinator) are untouched.

Docs

  • CLAUDE.md — Thesis #3 signature; the "constructors: Ok, Err, Defect" line (Defect drops out — not a constructor); the Result.* facade list.
  • Hand-written guide pages: root README.md, packages/core/README.md, docs/index.md, docs/guide/{boundaries,recipes,do-notation,core-concepts}.md.
  • Generated TypeDoc output is regenerated by build:docs, not hand-edited.
  • P.Defect / toBeDefect docs are about the Result variant, not the marker — untouched.

Compatibility

Breaking: the qualify contract changes and the Defect export is removed → major changeset.

Verification

Full gate green across the monorepo: pnpm format --check, pnpm lint, pnpm typecheck, pnpm knip, pnpm test, pnpm build, plus each package's build:docs typedoc-warning-free.

Released under the MIT License.