Skip to content

Commit 88bbcf4

Browse files
btraversclaude
andcommitted
fix(core): fail-fast Defect when bind/let gets a non-object scope
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>
1 parent d5f4256 commit 88bbcf4

5 files changed

Lines changed: 62 additions & 6 deletions

File tree

docs/guide/do-notation.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ the normal surface, so you can mix in `map`, `flatMap`, `match`, and the rest
3939
freely, and a thrown callback still becomes a `Defect`:
4040

4141
```ts
42+
import { Do, Ok } from "unthrown";
43+
4244
Do()
4345
.bind("n", () => Ok(2))
4446
.let("doubled", ({ n }) => n * 2)

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ features:
3939
## At a glance
4040

4141
```ts
42-
import { Ok, Err, fromPromise, type Result } from "unthrown";
42+
import { Ok, Err, Defect, fromPromise, type Result } from "unthrown";
4343

4444
class NotFound extends TaggedError("NotFound") {}
4545

packages/core/src/core.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ class Res<T, E> {
9999
if (r.tag !== "Ok") return passThrough(r);
100100
// The merged scope can't be spelled at the type level (a computed key
101101
// widens to an index signature), so the constructed Ok is cast to `Bound`.
102-
return okRes({ ...(this.value as object), [name]: r.value }) as unknown as Result<
102+
return okRes({ ...scopeOf(this.value), [name]: r.value }) as unknown as Result<
103103
Bound<T, K, U>,
104104
E | E2
105105
>;
@@ -115,7 +115,7 @@ class Res<T, E> {
115115
): Result<Bound<T, K, U>, E> {
116116
if (this.tag !== "Ok") return passThrough(this);
117117
try {
118-
return okRes({ ...(this.value as object), [name]: f(this.value) }) as unknown as Result<
118+
return okRes({ ...scopeOf(this.value), [name]: f(this.value) }) as unknown as Result<
119119
Bound<T, K, U>,
120120
E
121121
>;
@@ -321,6 +321,31 @@ function passThrough<T, E>(self: Result<unknown, unknown>): Result<T, E> {
321321
return self as unknown as Result<T, E>;
322322
}
323323

324+
/**
325+
* Validate that a `bind`/`let` scope is a real (non-null) object before merging a
326+
* key into it.
327+
*
328+
* @remarks
329+
* Do-notation accumulates an **object** scope: a chain starts at `Do()` (an
330+
* empty object) and every `bind`/`let` returns an object, so in typed code the
331+
* scope is always an object. The method lives on the general `Result` surface,
332+
* though, so a primitive `Ok` (e.g. `Ok(5).bind(...)`, or a chain whose value was
333+
* `map`-ped away from its scope) could reach it. Rather than let `{ ...5 }`
334+
* silently collapse to `{}` and drop the prior scope, we throw here — the
335+
* surrounding `try` turns it into a {@link Defect}, surfacing the misuse as the
336+
* bug it is (a defect is a bug, not an absent value). A `this: object` constraint
337+
* was rejected: TypeScript does not hard-enforce a constraint inferred solely
338+
* from `this`, and it breaks `AsyncRes implements AsyncResult`.
339+
*
340+
* @internal
341+
*/
342+
function scopeOf(value: unknown): object {
343+
if (typeof value !== "object" || value === null) {
344+
throw new TypeError("bind/let requires an object scope — start a do-chain with Do()");
345+
}
346+
return value;
347+
}
348+
324349
/**
325350
* The sole runtime implementation of {@link AsyncResult}: wraps a
326351
* `Promise<Result>` constructed never to reject. Operates on the public `Result`
@@ -406,7 +431,7 @@ export class AsyncRes<T, E> implements AsyncResult<T, E> {
406431
try {
407432
const inner = await f(r.value);
408433
if (inner.tag !== "Ok") return passThrough(inner);
409-
return okRes({ ...(r.value as object), [name]: inner.value }) as unknown as Result<
434+
return okRes({ ...scopeOf(r.value), [name]: inner.value }) as unknown as Result<
410435
Bound<T, K, U>,
411436
E | E2
412437
>;
@@ -422,7 +447,7 @@ export class AsyncRes<T, E> implements AsyncResult<T, E> {
422447
this.promise.then((r) => {
423448
if (r.tag !== "Ok") return passThrough(r);
424449
try {
425-
return okRes({ ...(r.value as object), [name]: f(r.value) }) as unknown as Result<
450+
return okRes({ ...scopeOf(r.value), [name]: f(r.value) }) as unknown as Result<
426451
Bound<T, K, U>,
427452
E
428453
>;

packages/core/src/do.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ describe("Do / bind / let", () => {
6262
.isDefect(),
6363
).toBe(true);
6464
});
65+
66+
it("turns bind/let on a non-object scope into a Defect (misuse, not a silent drop)", () => {
67+
// `bind`/`let` live on the general surface; calling them on a primitive `Ok`
68+
// would otherwise spread `{ ...5 }` into `{}` and silently drop the value.
69+
expect(
70+
Ok(5)
71+
.bind("a", () => Ok(1))
72+
.isDefect(),
73+
).toBe(true);
74+
expect(
75+
Ok(5)
76+
.let("a", () => 1)
77+
.isDefect(),
78+
).toBe(true);
79+
});
6580
});
6681

6782
describe("Do / bind / let — async", () => {
@@ -97,4 +112,16 @@ describe("Do / bind / let — async", () => {
97112
});
98113
expect(fromLet.isDefect()).toBe(true);
99114
});
115+
116+
it("turns an async bind/let on a non-object scope into a Defect", async () => {
117+
const fromBind = await Ok(5)
118+
.toAsync()
119+
.bind("a", () => Ok(1));
120+
expect(fromBind.isDefect()).toBe(true);
121+
122+
const fromLet = await Ok(5)
123+
.toAsync()
124+
.let("a", () => 1);
125+
expect(fromLet.isDefect()).toBe(true);
126+
});
100127
});

packages/core/src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ export type ResultMethods<T, E> = {
8484
* step. `f` receives the scope accumulated so far and returns a `Result`; on
8585
* `Ok` the value is added as `{ ...scope, [name]: value }`, on `Err`/`Defect`
8686
* the chain short-circuits. Errors union (`E | E2`). A throw becomes a
87-
* `Defect`. (`let` is the pure-value counterpart.)
87+
* `Defect` — as does calling `bind` on a non-object scope (e.g. `Ok(5).bind`),
88+
* which is misuse: the scope is always an object inside a real `Do()` chain.
89+
* (`let` is the pure-value counterpart.)
8890
*
8991
* @typeParam K - the key the bound value is stored under.
9092
* @typeParam U - the bound value type.

0 commit comments

Comments
 (0)