|
1 | 1 | # unthrown |
2 | 2 |
|
| 3 | +## 5.1.0 |
| 4 | + |
| 5 | +### Minor Changes |
| 6 | + |
| 7 | +- 5ead919: Close a boundary hole where an `async` function's rejection escaped |
| 8 | + qualification, stop `toBeErrTagged` counting a message as payload, and name the |
| 9 | + value in `NonExhaustiveError`. |
| 10 | + |
| 11 | + **`fromThrowable` / `fromSafeThrowable` now reject an `async` `fn`.** These wrap a |
| 12 | + **synchronous** function, so they only ever see a synchronous `throw`: an async |
| 13 | + `fn` rejects long after the boundary has returned, and its rejection could never |
| 14 | + reach `qualify`. It used to produce `Ok(<Promise>)` — un-triaged — and the |
| 15 | + rejection then floated as an unhandled rejection, which terminates the process on |
| 16 | + Node by default: |
| 17 | + |
| 18 | + ```ts |
| 19 | + const f = fromThrowable( |
| 20 | + async () => { |
| 21 | + throw new Error("boom"); |
| 22 | + }, |
| 23 | + (cause, defect) => defect(cause), |
| 24 | + ); |
| 25 | + f(); // before: Ok(<Promise>) + an unhandled rejection |
| 26 | + // now: Defect(TypeError: … `fn` returned a thenable …) |
| 27 | + ``` |
| 28 | + |
| 29 | + The orphaned rejection is adopted and silenced, so it cannot float. Reach for |
| 30 | + `fromPromise` / `fromSafePromise` for async work. |
| 31 | + |
| 32 | + This is caught at runtime rather than by the type system — the one thenable ban |
| 33 | + in the library that is not a compile error. Putting `NotThenable` on `fn`'s return |
| 34 | + also makes **generic** functions unassignable, so `fromSafeThrowable(structuredClone)` |
| 35 | + would stop compiling with `T` collapsed to `unknown`; the phantom rest-tuple guard |
| 36 | + `fromPromise` uses fares worse. The success type is therefore slightly over-stated |
| 37 | + (`Result<Promise<T>, E>` is spellable but never inhabited) — the mirror of |
| 38 | + `recoverErrCases`'s `never` under-stating the error channel. |
| 39 | + |
| 40 | + **`toBeErrTagged`'s exact-payload form now ignores every reserved key.** The |
| 41 | + documented way to set a `TaggedError`'s message is a subclass field — |
| 42 | + `override message = "…"` — which lands as an own **enumerable** property, so it |
| 43 | + leaked into the payload and failed an exact assertion on the very pattern the |
| 44 | + library prescribes: |
| 45 | + |
| 46 | + ```ts |
| 47 | + class HttpError extends TaggedError("HttpError")<{ status: number }> { |
| 48 | + override message = `http ${this.status}`; |
| 49 | + } |
| 50 | + expect(Err(new HttpError({ status: 500 }))).toBeErrTagged("HttpError", { |
| 51 | + status: 500, |
| 52 | + }); |
| 53 | + // before: failed — the payload was seen as { status: 500, message: "http 500" } |
| 54 | + // now: passes |
| 55 | + ``` |
| 56 | + |
| 57 | + The matcher now skips `_tag`, `name`, `message` and `stack` — exactly the keys |
| 58 | + `TaggedErrorInstance` omits and the constructor types `?: never`, so none of them |
| 59 | + can legitimately be payload. Assertions using `expect.objectContaining` are |
| 60 | + unaffected (they were already tolerant of the extra key). |
| 61 | + |
| 62 | + **`NonExhaustiveError` now names the value it could not match.** `JSON.stringify` |
| 63 | + _returns_ `undefined` — it does not throw — for a function, a symbol, or |
| 64 | + `undefined`, so the `String(input)` fallback never fired and the message read |
| 65 | + "no pattern matched the value undefined" for exactly the rogue inputs this error |
| 66 | + exists to describe. It now falls back correctly (`… the value function rogueFn() {}`). |
| 67 | + |
| 68 | +### Patch Changes |
| 69 | + |
| 70 | +- 625f985: Adopt a discarded thenable so its rejection can't float, and stop |
| 71 | + `createResultClient`'s proxy from ever exposing a callable `then`. |
| 72 | + |
| 73 | + **Core: the observers now adopt-and-silence a smuggled thenable.** `tap`, |
| 74 | + `tapErrCases`, `tapDefect` and `tapFailure` throw their callback's return value |
| 75 | + away, and the `Result`-returning combinators (`flatMap`, `flatTap`, `bind`, |
| 76 | + `flatMapErrCases`, `flatTapErrCases`, `recoverDefect`) reject a non-`Result` |
| 77 | + one. Either way a thenable that slipped past `NotThenable` — via a cast or a |
| 78 | + raw-JS caller — was dropped mid-flight, so a later rejection floated unhandled |
| 79 | + and took the process down on Node by default. Worse for an observer: its whole |
| 80 | + job is to make a failure visible, and this was the one path where the failure |
| 81 | + was invisible. |
| 82 | + |
| 83 | + The boundaries already did exactly this for a thenable `qualify` and a thenable |
| 84 | + `fn`; this is the same net on the combinator side. The observed result still |
| 85 | + passes through unchanged — silencing does not change any outcome. |
| 86 | + |
| 87 | + **`@unthrown/orpc`: `createResultClient` now answers `then` with `undefined`.** |
| 88 | + The proxy's `get` trap wraps every object/function property, so on a client |
| 89 | + whose own proxy answers _any_ key with a nested procedure, `rc.then` would be |
| 90 | + callable and `await rc` would invoke it. oRPC's client proxy happens to guard |
| 91 | + `then` today, but that is its invariant to change, not ours to depend on. |
| 92 | + |
3 | 93 | ## 5.0.0 |
4 | 94 |
|
5 | 95 | ### Major Changes |
|
0 commit comments