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.
// 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.ts—fromThrowable/fromPromisequalifybecomes(cause: unknown, defect: (cause: unknown) => Defect) => R.E = Exclude<R, Defect>is unchanged.qualifyToResultcallsqualify(cause, defect), passing the module-level marker fn by reference (no per-call allocation). TheisDefectMarkerbrand check stays — still how a returned marker is told apart from a modeledE. A throw insidequalifyis still aDefect.defect.ts— rename the marker fnDefect→defect(it is now an injected helper, not a constructor). Keep theDEFECTbrand, theDefecttype, andisDefectMarker. All remain module-exported; none is re-exported publicly.index.ts— deleteexport { Defect } from "./defect.js".facade.ts— dropResult.Defect.typedoc.json— add"Defect"tointentionallyNotExported(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/effectfromExit's replay →fromThrowable((): T => { throw … }, (_c, defect) => defect(…))().@unthrown/standard-schemafromSchema→(cause, defect) => defect(cause).
Tests
interop.spec.ts,async-result.spec.ts,types.test-d.ts— switchqualifycallbacks to the two-arg form.constructors.spec.ts— the marker unit test becomes "the injecteddefecthelper mints a marker", driven throughfromThrowablerather than importing the value.- The throw→defect
defectOf/asyncDefecthelpers (which fabricate a defect-stateResultvia 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); theResult.*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/toBeDefectdocs 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.