unthrown 5.0.0-beta.1 → 5.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,9 @@
10
10
  pnpm add unthrown
11
11
  ```
12
12
 
13
+ No peer dependencies — the exhaustive error matcher is built-in and exported as
14
+ `match` / `P`.
15
+
13
16
  ```ts
14
17
  import { fromPromise, P, TaggedError } from "unthrown";
15
18
 
@@ -22,23 +25,29 @@ const user = fromPromise(fetchUser(id), (cause, defect) =>
22
25
 
23
26
  const status = await user.match({
24
27
  ok: () => 200,
25
- err: (matcher) => matcher.with(P._, () => 404), // `err` takes the exhaustive matcher
28
+ // `errCases` takes the exhaustive matcher — every case of E named:
29
+ errCases: (matcher) => matcher.with(P.tag("NotFound"), () => 404),
26
30
  defect: () => 500,
27
31
  });
28
32
  ```
29
33
 
30
34
  - **Errors as values** via `Result<T, E>` / `AsyncResult<T, E>`.
31
35
  - **A separate defect channel** for the unexpected — invisible to the type,
32
- observable only via `match` / `recoverDefect`.
36
+ observable only via `match` / `recoverDefect` and the `tapDefect` /
37
+ `tapFailure` observers.
33
38
  - **Qualification at every boundary** — `fromPromise` / `fromThrowable` force you
34
39
  to triage each failure into a modeled error or a defect.
35
- - **Tagged errors** — `TaggedError(tag)` + `tag(t)`, folded exhaustively through
36
- `match`'s ts-pattern error matcher.
37
- - One tiny runtime dependency (`ts-pattern`), ESM-first, dual CJS/ESM.
40
+ - **Tagged errors** — `TaggedError(tag)` + `P.tag(t)`, folded exhaustively through
41
+ `match`'s built-in error matcher.
42
+ - **Zero runtime dependencies** (the matcher is built-in), ESM-first, dual
43
+ CJS/ESM.
38
44
 
39
45
  See the [full documentation](https://btravstack.github.io/unthrown/) for the guide
40
46
  and complete API.
41
47
 
48
+ **Upgrading from 4.x?** See
49
+ [Upgrade from 4.x to 5.0](https://btravstack.github.io/unthrown/how-to/upgrade-to-v5).
50
+
42
51
  ## License
43
52
 
44
53
  [MIT](https://github.com/btravstack/unthrown/blob/main/LICENSE) © Benoit TRAVERS
package/dist/index.cjs CHANGED
@@ -1,5 +1,179 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- let ts_pattern = require("ts-pattern");
2
+ //#region src/matcher.ts
3
+ /**
4
+ * Cross-copy brand for `P.*` pattern objects: `Symbol.for` yields the same
5
+ * symbol in every copy of the library (dual CJS/ESM, duplicated install,
6
+ * another realm), so a pattern built by one copy is recognised by another —
7
+ * the same rationale as `isResult`'s prototype brand.
8
+ *
9
+ * @internal
10
+ */
11
+ const PATTERN_BRAND = Symbol.for("unthrown.matcher.pattern");
12
+ /**
13
+ * Thrown by `.run()` / `.exhaustive()` when no arm matched the value. For
14
+ * well-typed callers the match is exhaustive by construction, so this is only
15
+ * reachable by a value that slipped past the types (a widened cast, a raw-JS
16
+ * caller); inside the error combinators the throw-to-defect net converts it to
17
+ * a `Defect`, and at the `match` edge it surfaces (a genuinely unmodeled value
18
+ * is a bug).
19
+ *
20
+ * @category Errors
21
+ */
22
+ var NonExhaustiveError = class extends Error {
23
+ /** The value no arm matched. */
24
+ input;
25
+ constructor(input) {
26
+ let printed;
27
+ try {
28
+ printed = JSON.stringify(input);
29
+ } catch {
30
+ printed = String(input);
31
+ }
32
+ super(`unthrown: no pattern matched the value ${printed}`);
33
+ this.name = "NonExhaustiveError";
34
+ this.input = input;
35
+ Object.setPrototypeOf(this, new.target.prototype);
36
+ }
37
+ };
38
+ /**
39
+ * Is `x` a *plain* object (prototype `Object.prototype` or `null`) — an object
40
+ * literal, the only object shape that acts as a structural pattern?
41
+ *
42
+ * @internal
43
+ */
44
+ function isPlainObject(x) {
45
+ const proto = Object.getPrototypeOf(x);
46
+ return proto === Object.prototype || proto === null;
47
+ }
48
+ /**
49
+ * Runtime test: does `pattern` match `value`? A branded `P.*` pattern applies
50
+ * its predicate; a **plain-object** pattern (an object literal, e.g. the
51
+ * `{ _tag }` produced by `P.tag()`) matches when every key matches recursively
52
+ * (extra keys on the value are ignored — matching is structural); anything
53
+ * else — primitives, but also class instances, arrays, and foreign pattern
54
+ * objects (e.g. a real ts-pattern matcher, whose keys are symbols) — is
55
+ * compared with `Object.is`. Restricting structural matching to plain objects
56
+ * is load-bearing: a keyless non-plain object (`new Date()`, `new Error()`, a
57
+ * symbol-keyed foreign pattern) would otherwise vacuously match *every* object
58
+ * via an empty `Object.entries`.
59
+ *
60
+ * @internal
61
+ */
62
+ function matches(pattern, value) {
63
+ if (typeof pattern === "object" && pattern !== null) {
64
+ const predicate = pattern[PATTERN_BRAND];
65
+ if (typeof predicate === "function") return predicate(value);
66
+ if (!isPlainObject(pattern) || Object.getOwnPropertySymbols(pattern).length > 0) return Object.is(pattern, value);
67
+ if (typeof value !== "object" || value === null) return false;
68
+ return Object.entries(pattern).every(([key, sub]) => matches(sub, value[key]));
69
+ }
70
+ return Object.is(pattern, value);
71
+ }
72
+ /**
73
+ * The runtime builder: first matching arm wins; later arms are skipped once a
74
+ * result is captured. `exhaustive` is a *method* at runtime (the conditional
75
+ * type gates its callability per instantiation).
76
+ *
77
+ * @internal
78
+ */
79
+ var MatcherImpl = class {
80
+ #value;
81
+ #matched = false;
82
+ #result;
83
+ constructor(value) {
84
+ this.#value = value;
85
+ }
86
+ with(...args) {
87
+ if (this.#matched) return this;
88
+ const handler = args[args.length - 1];
89
+ for (let i = 0; i < args.length - 1; i++) if (matches(args[i], this.#value)) {
90
+ this.#matched = true;
91
+ this.#result = handler(this.#value);
92
+ return this;
93
+ }
94
+ return this;
95
+ }
96
+ /**
97
+ * Type-level only — pinning the output type has no runtime meaning, so the
98
+ * builder is returned unchanged (as ts-pattern does).
99
+ */
100
+ returnType() {
101
+ return this;
102
+ }
103
+ exhaustive() {
104
+ if (this.#matched) return this.#result;
105
+ throw new NonExhaustiveError(this.#value);
106
+ }
107
+ run() {
108
+ return this.exhaustive();
109
+ }
110
+ };
111
+ Object.freeze(MatcherImpl.prototype);
112
+ /**
113
+ * Begin a match over `value`. Chain `.with(pattern, …patterns, handler)` arms;
114
+ * terminate with `.exhaustive()` — or return the un-terminated builder to an
115
+ * unthrown error combinator / `match({ errCases })`, which runs it for you.
116
+ *
117
+ * @remarks
118
+ * This is unthrown's own matcher (the former ts-pattern re-export): the same
119
+ * call-site shape, with exhaustiveness computed by plain `Exclude` over the
120
+ * builder's `Remaining` parameter. Name every case of the input union; the
121
+ * `P._` catch-all is the escape hatch, and is provably exhaustive even over an
122
+ * unresolved generic input — one of the two cases it is irreplaceable for (see
123
+ * {@link P}).
124
+ *
125
+ * @category Constructors
126
+ */
127
+ function match(value) {
128
+ return new MatcherImpl(value);
129
+ }
130
+ /** @internal */
131
+ function pattern(predicate) {
132
+ return Object.freeze({ [PATTERN_BRAND]: predicate });
133
+ }
134
+ const universal = pattern(() => true);
135
+ /**
136
+ * The pattern namespace (unthrown's own; the former ts-pattern `P`):
137
+ *
138
+ * - `P._` / `P.any` — the universal catch-all, and an **escape hatch** rather
139
+ * than the default: matching the error channel means naming its cases, so
140
+ * reach for this only where they cannot be named. Matches anything, and
141
+ * (because its phantom type is `unknown`) makes the builder provably
142
+ * exhaustive even when the matched input is an unresolved type parameter.
143
+ * Two situations are legitimate: a **helper generic in `E`**, where no arm
144
+ * list can prove exhaustiveness against an unresolved type parameter; and an
145
+ * **`E` that is a single type**, not a union of cases (a validator's issues
146
+ * array, say), where one arm _is_ the enumeration. `@unthrown/oxlint`'s
147
+ * `no-catch-all-pattern` (in its `recommended` preset) flags every other use;
148
+ * keep the deliberate ones behind a targeted `oxlint-disable` saying which of
149
+ * the two it is.
150
+ * - `P.tag<const Tag extends string>(value: Tag): { _tag: Tag }` — the
151
+ * `{ _tag: t }` object pattern, matching any value whose `_tag` equals `t` (a
152
+ * `TaggedError`, or any `_tag`-discriminated member) and narrowing the
153
+ * branch's parameter to that variant, payload included. The workhorse of the
154
+ * error channel: `matcher.with(P.tag("NotFound"), (e) => …)`. It composes like
155
+ * any other pattern — in a grouped arm
156
+ * (`.with(P.tag("A"), P.tag("B"), handler)`) and inside `P.union`.
157
+ * - `P.instanceOf(Cls)` — an `instanceof` check, narrowing to the class
158
+ * instance type (for union members that are not tagged, e.g. a third-party
159
+ * error class).
160
+ * - `P.when(guard)` — an arbitrary type-guard predicate.
161
+ * - `P.union(…patterns)` — matches when any sub-pattern matches.
162
+ * - `P.string` / `P.number` — primitive-type wildcards.
163
+ *
164
+ * @category Constructors
165
+ */
166
+ const P = Object.freeze({
167
+ _: universal,
168
+ any: universal,
169
+ tag: (value) => ({ _tag: value }),
170
+ instanceOf: (cls) => pattern((value) => value instanceof cls),
171
+ when: (guard) => pattern(guard),
172
+ union: (...patterns) => pattern((value) => patterns.some((sub) => matches(sub, value))),
173
+ string: pattern((value) => typeof value === "string"),
174
+ number: pattern((value) => typeof value === "number")
175
+ });
176
+ //#endregion
3
177
  //#region src/defect.ts
4
178
  const DEFECT = Symbol("unthrown/Defect");
5
179
  /**
@@ -152,7 +326,7 @@ var Res = class {
152
326
  return defectRes(cause);
153
327
  }
154
328
  }
155
- mapErr(f) {
329
+ mapErrCases(f) {
156
330
  if (this.tag !== "Err") return passThrough(this);
157
331
  try {
158
332
  const out = runMatch(f, this.error);
@@ -162,7 +336,7 @@ var Res = class {
162
336
  return defectRes(cause);
163
337
  }
164
338
  }
165
- flatMapErr(f) {
339
+ flatMapErrCases(f) {
166
340
  if (this.tag !== "Err") return passThrough(this);
167
341
  try {
168
342
  const out = runMatch(f, this.error);
@@ -173,7 +347,7 @@ var Res = class {
173
347
  return defectRes(cause);
174
348
  }
175
349
  }
176
- recoverErr(f) {
350
+ recoverErrCases(f) {
177
351
  if (this.tag !== "Err") return passThrough(this);
178
352
  try {
179
353
  const out = runMatch(f, this.error);
@@ -183,19 +357,21 @@ var Res = class {
183
357
  return defectRes(cause);
184
358
  }
185
359
  }
186
- tapErr(f) {
360
+ tapErrCases(f) {
187
361
  if (this.tag !== "Err") return this;
188
362
  try {
189
- runMatch(f, this.error);
363
+ const out = runMatch(f, this.error);
364
+ if (isDefectMarker(out)) return observerThrowToDefect(out.cause, this.error);
190
365
  return this;
191
366
  } catch (cause) {
192
367
  return observerThrowToDefect(cause, this.error);
193
368
  }
194
369
  }
195
- flatTapErr(f) {
370
+ flatTapErrCases(f) {
196
371
  if (this.tag !== "Err") return this;
197
372
  try {
198
373
  const r = runMatch(f, this.error);
374
+ if (isDefectMarker(r)) return observerThrowToDefect(r.cause, this.error);
199
375
  if (!isResult(r)) return nonResultCallbackDefect();
200
376
  return r.tag === "Ok" ? this : passThrough(r);
201
377
  } catch (cause) {
@@ -232,7 +408,7 @@ var Res = class {
232
408
  match(cases) {
233
409
  switch (this.tag) {
234
410
  case "Ok": return cases.ok(this.value);
235
- case "Err": return cases.err((0, ts_pattern.match)(this.error)).run();
411
+ case "Err": return cases.errCases(match(this.error)).run();
236
412
  case "Defect": return cases.defect(this.cause);
237
413
  }
238
414
  }
@@ -350,14 +526,18 @@ function defectRes(cause) {
350
526
  *
351
527
  * @example
352
528
  * ```ts
353
- * import { isResult, Ok } from "unthrown";
529
+ * import { isResult, Ok, P } from "unthrown";
354
530
  *
355
531
  * isResult(Ok(1)); // => true
356
532
  * isResult({ tag: "Ok" }); // => false (look-alike, wrong prototype)
357
533
  * isResult(Ok(1).toAsync()); // => false (an AsyncResult is not a Result)
358
534
  *
359
535
  * const x: unknown = Ok(1);
360
- * if (isResult(x)) x.match({ ok: () => 1, err: () => 0, defect: () => -1 });
536
+ * if (isResult(x))
537
+ * // `E` is `unknown` here — an untyped boundary has no cases to enumerate,
538
+ * // so the `P._` escape hatch is the only arm that can terminate the match:
539
+ * // oxlint-disable-next-line unthrown/no-catch-all-pattern -- untyped boundary: `E` is `unknown`
540
+ * x.match({ ok: () => 1, errCases: (m) => m.with(P._, () => 0), defect: () => -1 });
361
541
  * ```
362
542
  *
363
543
  * @category Guards
@@ -407,19 +587,24 @@ function nonResultCallbackDefect() {
407
587
  * @internal
408
588
  */
409
589
  function runMatch(f, error) {
410
- return f((0, ts_pattern.match)(error), defect).run();
590
+ return f(match(error), defect).run();
411
591
  }
412
592
  /**
413
- * A throw inside a *failure observer* (`tapErr` / `tapDefect` / `flatTapErr`)
414
- * must not destroy the failure being observed — that is the exact place (e.g. a
415
- * failing error-logger) where losing the underlying failure hurts most. The
416
- * resulting Defect aggregates both: `errors[0]` is the observer's throw,
417
- * `errors[1]` the original failure.
593
+ * A throw inside a *failure observer* (`tapErrCases` / `tapDefect` /
594
+ * `tapFailure` / `flatTapErrCases`) must not destroy the failure being observed
595
+ * — that is the exact place (e.g. a failing error-logger) where losing the
596
+ * underlying failure hurts most. The resulting Defect aggregates both:
597
+ * `errors[0]` is the observer's own failure, `errors[1]` the original failure.
598
+ *
599
+ * An observer branch returning the injected `defect(cause)` marker
600
+ * (`tapErrCases` / `flatTapErrCases`) takes the same route: it is the
601
+ * lint-clean, expression-position form of a `throw` (Thesis #5), so it must not
602
+ * behave differently from one.
418
603
  *
419
604
  * @internal
420
605
  */
421
606
  function observerThrowToDefect(thrown, original) {
422
- return defectRes(new AggregateError([thrown, original], "unthrown: a failure-observer callback threw; errors[0] is the callback's throw, errors[1] the original failure"));
607
+ return defectRes(new AggregateError([thrown, original], "unthrown: a failure-observer callback failed; errors[0] is the callback's failure (a throw, or a deliberate defect), errors[1] the original failure"));
423
608
  }
424
609
  /**
425
610
  * Validate that a `bind`/`let` scope is a real (non-null) object before merging a
@@ -547,7 +732,7 @@ var AsyncRes = class AsyncRes {
547
732
  }
548
733
  }));
549
734
  }
550
- mapErr(f) {
735
+ mapErrCases(f) {
551
736
  return new AsyncRes(this.#promise.then((r) => {
552
737
  if (r.tag !== "Err") return passThrough(r);
553
738
  try {
@@ -559,7 +744,7 @@ var AsyncRes = class AsyncRes {
559
744
  }
560
745
  }));
561
746
  }
562
- flatMapErr(f) {
747
+ flatMapErrCases(f) {
563
748
  return new AsyncRes(this.#promise.then(async (r) => {
564
749
  if (r.tag !== "Err") return passThrough(r);
565
750
  try {
@@ -573,7 +758,7 @@ var AsyncRes = class AsyncRes {
573
758
  }
574
759
  }));
575
760
  }
576
- recoverErr(f) {
761
+ recoverErrCases(f) {
577
762
  return new AsyncRes(this.#promise.then((r) => {
578
763
  if (r.tag !== "Err") return passThrough(r);
579
764
  try {
@@ -585,22 +770,25 @@ var AsyncRes = class AsyncRes {
585
770
  }
586
771
  }));
587
772
  }
588
- tapErr(f) {
773
+ tapErrCases(f) {
589
774
  return new AsyncRes(this.#promise.then((r) => {
590
775
  if (r.tag !== "Err") return r;
591
776
  try {
592
- runMatch(f, r.error);
777
+ const out = runMatch(f, r.error);
778
+ if (isDefectMarker(out)) return observerThrowToDefect(out.cause, r.error);
593
779
  return r;
594
780
  } catch (cause) {
595
781
  return observerThrowToDefect(cause, r.error);
596
782
  }
597
783
  }));
598
784
  }
599
- flatTapErr(f) {
785
+ flatTapErrCases(f) {
600
786
  return new AsyncRes(this.#promise.then(async (r) => {
601
787
  if (r.tag !== "Err") return passThrough(r);
602
788
  try {
603
- const inner = await runMatch(f, r.error);
789
+ const out = runMatch(f, r.error);
790
+ if (isDefectMarker(out)) return observerThrowToDefect(out.cause, r.error);
791
+ const inner = await out;
604
792
  if (!isResult(inner)) return nonResultCallbackDefect();
605
793
  return inner.tag === "Ok" ? passThrough(r) : passThrough(inner);
606
794
  } catch (cause) {
@@ -1006,6 +1194,11 @@ function fromSafeThrowable(fn) {
1006
1194
  * @param promise - the promise, or a thunk returning one.
1007
1195
  * @param qualify - triages a rejection `cause` into a modeled `E`, or marks it
1008
1196
  * unmodeled by returning `defect(cause)` (the helper passed as its second arg).
1197
+ * @param _guard - compile-time only; never pass it. The phantom rest-tuple that
1198
+ * enforces "qualify is synchronous": an `async` qualify makes this demand an
1199
+ * impossible extra argument (whose type spells out the error), while a
1200
+ * synchronous one leaves it empty. Encoded here — not on `qualify`'s return
1201
+ * type — so `T`'s inference from `promise` is undisturbed.
1009
1202
  *
1010
1203
  * @category Interop
1011
1204
  *
@@ -1022,7 +1215,7 @@ function fromSafeThrowable(fn) {
1022
1215
  * // when fetchUser rejects with NotFoundError: user is Err("not_found")
1023
1216
  * ```
1024
1217
  */
1025
- function fromPromise(promise, qualify) {
1218
+ function fromPromise(promise, qualify, ..._guard) {
1026
1219
  const triage = qualify;
1027
1220
  return new AsyncRes((typeof promise === "function" ? Promise.resolve().then(promise) : Promise.resolve(promise)).then((value) => okRes(value), (cause) => qualifyToResult(cause, triage)));
1028
1221
  }
@@ -1336,10 +1529,14 @@ const AsyncResult = {
1336
1529
  * so a payload `cause` (e.g. a wrapped driver error) is a legitimate,
1337
1530
  * *narrowing* structured field.
1338
1531
  *
1339
- * `_tag` is the discriminant matched by {@link tag} in the error combinators
1340
- * (`result.mapErr((matcher) => matcher.with(tag("NotFound"), …))`) and in
1341
- * `match`; `Error.name` is the human-facing label in stack traces and logs. By
1342
- * default they coincide, but
1532
+ * The matching half of the convention is `P.tag(t)` — the pattern constructor on
1533
+ * the `P` namespace, which builds the `{ _tag: t }` pattern this factory's `_tag`
1534
+ * is selected by (there is no standalone `tag` export).
1535
+ *
1536
+ * `_tag` is the discriminant matched by `P.tag` in the error combinators
1537
+ * (`result.mapErrCases((matcher) => matcher.with(P.tag("NotFound"), …))`) and in
1538
+ * `match`'s `errCases` handler; `Error.name` is the human-facing label in stack
1539
+ * traces and logs. By default they coincide, but
1343
1540
  * they can be **decoupled** with `options.name` — so a tag can be namespaced for
1344
1541
  * collision-safety (`"@my-lib/RetryableError"`) without that slash-prefixed
1345
1542
  * string leaking into `Error.name`:
@@ -1398,29 +1595,6 @@ function TaggedError(tag, options) {
1398
1595
  }
1399
1596
  return TaggedErrorBase;
1400
1597
  }
1401
- /**
1402
- * A `ts-pattern` pattern matching any value whose `_tag` equals `value` — a
1403
- * {@link TaggedError}, or any discriminated member. Equivalent to the object
1404
- * pattern `{ _tag: value }`, but reads better inside an error-matching
1405
- * combinator and narrows to the matching variant, payload included.
1406
- *
1407
- * @typeParam Tag - the string literal tag to match.
1408
- * @param value - the `_tag` to match.
1409
- *
1410
- * @category Tagged errors
1411
- *
1412
- * @example
1413
- * ```ts
1414
- * result.mapErr((matcher) =>
1415
- * matcher
1416
- * .with(tag("NotFound"), () => new NotFoundException())
1417
- * .with(tag("Conflict"), (e) => new ConflictException(e.key)),
1418
- * );
1419
- * ```
1420
- */
1421
- function tag(value) {
1422
- return { _tag: value };
1423
- }
1424
1598
  //#endregion
1425
1599
  exports.AsyncResult = AsyncResult;
1426
1600
  exports.Do = Do;
@@ -1428,14 +1602,10 @@ exports.DoAsync = DoAsync;
1428
1602
  exports.Err = Err;
1429
1603
  exports.ErrAsync = ErrAsync;
1430
1604
  exports.GetError = GetError;
1605
+ exports.NonExhaustiveError = NonExhaustiveError;
1431
1606
  exports.Ok = Ok;
1432
1607
  exports.OkAsync = OkAsync;
1433
- Object.defineProperty(exports, "P", {
1434
- enumerable: true,
1435
- get: function() {
1436
- return ts_pattern.P;
1437
- }
1438
- });
1608
+ exports.P = P;
1439
1609
  exports.Result = Result;
1440
1610
  exports.TaggedError = TaggedError;
1441
1611
  exports.all = all;
@@ -1451,10 +1621,4 @@ exports.isDefect = isDefect;
1451
1621
  exports.isErr = isErr;
1452
1622
  exports.isOk = isOk;
1453
1623
  exports.isResult = isResult;
1454
- Object.defineProperty(exports, "match", {
1455
- enumerable: true,
1456
- get: function() {
1457
- return ts_pattern.match;
1458
- }
1459
- });
1460
- exports.tag = tag;
1624
+ exports.match = match;