feat(core): add do-notation (Do / bind / let)#29
Merged
Conversation
Add `Do()` plus `bind`/`let` methods on Result and AsyncResult for sequencing dependent steps into a named, accumulating object scope — without nested flatMap closures, and without generators. - `bind(name, f)`: f returns a Result; its value is bound under `name` in a readonly object scope; error types union (E | E2). On AsyncResult, f may return a Result or an AsyncResult. - `let(name, f)`: binds a pure value. - A throw in either becomes a Defect; Err/Defect short-circuits — same invariants as every combinator (guarded in invariants.spec). `Do` is capitalised because `do` is reserved; lift a sync chain with `toAsync()` to go async. Runtime + type-level tests, docs (a Do Notation guide + sidebar/surface/cheatsheet), and CLAUDE.md updated (fluent do-notation moved off the "excluded" list; generator `gen`/`safeTry` style stays out). 100% line/function coverage held. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds fluent do-notation to unthrown’s Result / AsyncResult surface via a new Do() entry point plus bind/let chaining methods, with accompanying tests, documentation, and a changeset.
Changes:
- Introduces
Do()plusbind(name, f)andlet(name, f)for sequential, scope-accumulating pipelines on bothResultandAsyncResult. - Adds runtime + type-level tests to enforce short-circuiting and invariants (throw→Defect, Defect passthrough).
- Documents the new feature and wires it into the VitePress sidebar and project spec/changeset.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/core/src/types.ts | Adds Prettify and extends the public method surface with bind/let typings. |
| packages/core/src/types.test-d.ts | Adds type assertions for scope accumulation, readonly keys, and async parity. |
| packages/core/src/invariants.spec.ts | Extends invariant tests to cover bind/let throw→Defect and Defect passthrough. |
| packages/core/src/interop.ts | Adjusts casts in all* helpers to accommodate widened scope typing interactions. |
| packages/core/src/index.ts | Exports Do from the core package entry point. |
| packages/core/src/facade.ts | Exposes Do on the Result companion facade for discoverability. |
| packages/core/src/do.ts | Implements Do() as the empty-scope seed (ok({})). |
| packages/core/src/do.spec.ts | Adds runtime tests for do-notation behavior (sync + async). |
| packages/core/src/core.ts | Implements bind/let on Result and AsyncResult runtime classes. |
| docs/guide/do-notation.md | Adds a new guide page explaining Do/bind/let (sync + async usage). |
| docs/guide/core-concepts.md | Updates method-surface overview to include do-notation. |
| docs/guide/choosing-a-combinator.md | Adds do-notation to the “Choosing a Combinator” cheatsheet table. |
| docs/.vitepress/config.ts | Adds “Do Notation” to the guide sidebar nav. |
| CLAUDE.md | Updates library spec/invariants and clarifies generator do-notation remains excluded. |
| .changeset/do-notation.md | Publishes a minor changeset describing the new API. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
btravers
commented
Jun 27, 2026
Address PR review: the scope type used `T & { [K]: U }`, so re-binding an
existing name produced an unsound `T[K] & U` intersection while the
runtime spread overwrites. Introduce `Bound<T, K, U> = Prettify<Omit<T, K>
& { readonly [P in K]: U }>` so the type matches the overwrite. Applied to
both bind/let on Result and AsyncResult, with a type-level test for the
re-bind case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The short-circuit branches reused a non-matching variant (`Err`/`Defect`) as a differently-typed `Result` via inline `as unknown as` at ~21 sites. Centralise that single sound assertion in one `passThrough<T, E>(self): Result<T, E>` helper; every combinator's pass-through (sync + async) now calls it. `return this` is preserved, so nothing is reconstructed and no new object is allocated — only the phantom type parameter moves. Trade-off chosen deliberately: boxed casts inline (scattered), neverthrow reconstructs (allocates); this keeps boxed's zero-alloc while collapsing the casts to a single documented home. The only remaining casts are the bind/let scope merge (a computed key can't be spelled at the type level) and the builders' construction. Behaviour unchanged; 151 tests + the type-level suite pass, 100% line/function coverage held. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align the value constructors with the discriminated-union tags and the capitalized `Do`. Facade (`Result.Ok`/`Err`/`Defect`) and pattern constructors (`P.Ok`/`Err`/`Defect`) follow. Match handler keys, the `is*` guards, and the "defect channel" terminology are unchanged. `Err` (not `Error`) avoids shadowing the global. BREAKING CHANGE: ok/err/defect renamed to Ok/Err/Defect across unthrown and @unthrown/pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (1)
packages/effect/README.md:28
- After renaming constructors to
Ok/Err, this snippet still usesok/errin the.match(...)handlers, but those identifiers are no longer imported/defined, so the example is broken. Use explicit lambdas (or otherwise define handlers) instead of relying on the old constructor names.
bind/let live on the general Result surface, so a primitive Ok (e.g.
Ok(5).bind(...)) could reach the scope merge and `{ ...5 }` would
silently collapse to `{}`, dropping the prior scope. Route the merge
through a `scopeOf` guard that throws on a non-object value; the
surrounding try turns it into a Defect — misuse surfaced as the bug it
is, not a silent drop. Covers sync + async bind/let. A `this: object`
constraint was rejected: TS does not hard-enforce a constraint inferred
solely from `this`, and it breaks `AsyncRes implements AsyncResult`.
Also fix two doc snippets to import the constructors they use
(do-notation `Ok`, index `Defect`).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements fluent do-notation —
Do().bind(name, fn).let(name, fn)— in the style of neverthrow #680, adapted to unthrown's three-channel model.API
Do()— entry point:ok({}), an empty object scope. Capitalised becausedois reserved.bind(name, f)—freceives the scope so far and returns aResult; onOkits value is added undername(a readonly accumulating object), onErr/Defectthe chain short-circuits. Error types union (E | E2). OnAsyncResult,fmay return aResultor anAsyncResult(never a rawPromise— Thesis docs: sync to shipped APIs + fix format pre-commit hook #3).let(name, f)— the pure-value counterpart.Resultat every step, so you can interleavemap/flatMap/match/etc. To go async, lift withtoAsync().Invariants preserved
A throw in any
bind/letcallback becomes aDefect;Err/Defectshort-circuits/passes through. Both are added to the throw→Defect and Defect-passthrough guards ininvariants.spec.ts.A note on scope
This reverses a stated exclusion —
CLAUDE.mdhad do-notation on the "deliberately excluded" list. Per your request I've shipped the fluent form and updated the spec; the generator (gen/safeTry) style remains out of scope. The one cost:bind/let'sT & {…}return type forced four casts ininterop.ts(theall*family) to widen fromastoas unknown as— noted in the diff.Tests & docs
do.spec.ts(sync + async: accumulation, short-circuit, error union, defect passthrough, throw→defect) and threetypes.test-d.tsassertions (scope accumulation, error union, readonly keys, async parity).CLAUDE.mdsurface/invariant/facade updated; minor changeset.🤖 Generated with Claude Code