Skip to content

Commit f5c487c

Browse files
w01fgangclaude
andcommitted
feat!: remove thenable behavior from Try instances
Try is no longer thenable for any wrapped function. Callers must use .value()/.unwrap()/.error()/.result() to execute. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f8647f4 commit f5c487c

9 files changed

Lines changed: 31 additions & 147 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
'@power-rent/try-catch': major
3+
---
4+
Remove thenable behavior from `Try` instances entirely. No `Try` instance is ever thenable — `await new Try(fn)` yields the `Try` instance itself regardless of whether the wrapped function is sync or async. Callers must use `.value()`, `.unwrap()`, `.error()`, or `.result()` to execute and read the result. Migration: replace `await new Try(asyncFn, ...args)` with `await new Try(asyncFn, ...args).value()` (or `.unwrap()` / `.result()` / `.error()` depending on desired semantics).

README.md

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ import { Try } from '@power-rent/try-catch';
6565

6666
## Sync vs Async
6767

68-
Only `async` functions (declared with the `async` keyword) produce a thenable `Try` instance. Everything else — sync functions, *and sync functions that happen to return a Promise* — must be consumed via a terminal method.
68+
`Try` instances are never thenable. Whether the wrapped function is sync or async, you must call a terminal method (`.value()`, `.unwrap()`, `.error()`, or `.result()`) to run it and read the outcome.
6969

70-
**Async functions**`await` the terminal method (or the `Try` instance directly):
70+
**Async functions**`await` the terminal method:
7171

7272
```ts doctest
7373
import { Try } from '@power-rent/try-catch';
@@ -76,14 +76,10 @@ async function asyncFn(arg: number) {
7676
return arg * 2;
7777
}
7878

79-
// Async function: await the terminal call
8079
const result = await new Try(asyncFn, 21).value();
8180

82-
// Or await the Try instance itself (works for async functions only)
83-
const same = await new Try(asyncFn, 21);
84-
85-
if (result !== 42 || same !== 42) {
86-
throw new Error(`expected 42, got ${String(result)} / ${String(same)}`);
81+
if (result !== 42) {
82+
throw new Error(`expected 42, got ${String(result)}`);
8783
}
8884
```
8985

@@ -103,7 +99,7 @@ function returnsPromise(): Promise<number> {
10399
}
104100
const n = await new Try(returnsPromise).value();
105101

106-
// Awaiting a non-async Try yields the Try instance, NOT the result.
102+
// Awaiting a Try instance directly yields the Try instance, NOT the result.
107103
// The instance is not thenable, so `await` cannot trigger execution.
108104
// Use .value() / .unwrap() / .error() / .result() instead.
109105
if ((result as { ok: boolean }).ok !== true || n !== 42) {
@@ -355,9 +351,9 @@ Execute and return a discriminated union:
355351
Breadcrumbs are recorded on the error branch.
356352

357353
Sync functions return values immediately; async functions return Promises.
358-
Only `AsyncFunction`-wrapped `Try` instances are awaitable —
359-
`await new Try(nonAsyncFn)` yields the `Try` instance itself, use
360-
`.value()` / `.unwrap()` / `.error()` / `.result()` instead.
354+
`Try` instances are never thenable — `await new Try(fn)` yields the `Try`
355+
instance itself regardless of whether the wrapped function is sync or async.
356+
Use `.value()` / `.unwrap()` / `.error()` / `.result()` to execute.
361357

362358
## Examples
363359

docs/ARCHITECTURE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,15 @@ When `.report()` is **not** configured but `.breadcrumbs()` is, each terminal me
8686

8787
## Sync vs async execution paths
8888

89-
The library resolves the sync/async split at construction time using a single check:
89+
The library resolves the sync/async split at terminal-method call time. `Try` instances are **never thenable** — no `.then` is ever installed — so `await new Try(fn)` always yields the `Try` instance itself without triggering execution. Any thenability probe (`Promise.resolve`, `util.inspect`, deep-equality matchers, serializers) is guaranteed not to invoke the wrapped function.
90+
91+
Callers consume the result with `.value()`, `.unwrap()`, `.result()`, or `.error()`. Each terminal routes through `execute()`:
9092

9193
**Async path (declared `async` functions)**
92-
`fn.constructor.name === 'AsyncFunction'` is true. `installThenable()` defines `.then` as an owned data property immediately, so `await new Try(asyncFn)` works without executing the function early.
94+
`fn.constructor.name === 'AsyncFunction'` is true. The terminal returns a `Promise<...>` that callers `await`.
9395

9496
**Non-async path (everything else)**
95-
No `.then` property is installed. The instance is **not thenable**, so `await new Try(nonAsyncFn)` yields the `Try` instance itself rather than triggering execution. This holds even when the wrapped function happens to return a `Promise` — any thenability probe (`Promise.resolve`, `util.inspect`, deep-equality matchers, serializers) is guaranteed not to invoke the wrapped function. Callers must use `.value()`, `.unwrap()`, `.result()`, or `.error()` directly. Each terminal routes through `execute()`, which detects a `Promise` return value and returns a `Promise<TryResult>` so awaiting the terminal still works.
97+
The terminal runs synchronously. If the wrapped function happens to return a `Promise`, `execute()` detects it and returns a `Promise<TryResult>` so `await` still works on the terminal call.
9698

9799
Both paths cache the result in `this.exec` so that subsequent terminal method calls return the same settled value.
98100

docs/GETTING-STARTED.md

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,9 @@ Try.setDefaultReporter(new ConsoleReporter());
9696

9797
## Sync vs Async
9898

99-
Only `async` functions (declared with the `async` keyword) produce a thenable `Try` instance. Everything else — sync functions, *and sync functions that happen to return a Promise* — must be consumed via a terminal method.
99+
`Try` instances are never thenable. Whether the wrapped function is sync or async, you must call a terminal method (`.value()`, `.unwrap()`, `.error()`, or `.result()`) to run it and read the outcome.
100100

101-
**Async functions**`await` the terminal method (or the `Try` instance directly):
101+
**Async functions**`await` the terminal method:
102102

103103
```ts doctest
104104
import { Try } from '@power-rent/try-catch';
@@ -107,14 +107,10 @@ async function asyncFn(arg: number) {
107107
return arg * 2;
108108
}
109109

110-
// Async function: await the terminal call
111110
const result = await new Try(asyncFn, 21).value();
112111

113-
// Or await the Try instance itself (works for async functions only)
114-
const same = await new Try(asyncFn, 21);
115-
116-
if (result !== 42 || same !== 42) {
117-
throw new Error(`expected 42, got ${String(result)} / ${String(same)}`);
112+
if (result !== 42) {
113+
throw new Error(`expected 42, got ${String(result)}`);
118114
}
119115
```
120116

@@ -134,7 +130,7 @@ function returnsPromise(): Promise<number> {
134130
}
135131
const n = await new Try(returnsPromise).value();
136132

137-
// Awaiting a non-async Try yields the Try instance, NOT the result.
133+
// Awaiting a Try instance directly yields the Try instance, NOT the result.
138134
// The instance is not thenable, so `await` cannot trigger execution.
139135
// Use .value() / .unwrap() / .error() / .result() instead.
140136
if ((result as { ok: boolean }).ok !== true || n !== 42) {
@@ -163,7 +159,7 @@ npm install @sentry/nextjs
163159

164160
Supported version range: `>=8.0.0 <11.0.0`.
165161

166-
**`await new Try(syncFn)` returns the `Try` instance** — This is expected behaviour. Awaiting a `Try` that wraps a non-async function (sync, or sync-returning-Promise) yields the `Try` instance itself because the instance is not thenable. Use `.value()`, `.unwrap()`, `.error()`, or `.result()` to retrieve the result.
162+
**`await new Try(fn)` returns the `Try` instance** — This is expected behaviour for every wrapped function (sync or async). `Try` instances are not thenable, so `await` cannot trigger execution. Use `.value()`, `.unwrap()`, `.error()`, or `.result()` to retrieve the result.
167163

168164
## Next Steps
169165

src/__tests__/Try.test.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,8 @@ describe('Try', () => {
224224

225225
await new Try(throwingFunction, params)
226226
.debug(false)
227-
.breadcrumbs(['parameterKey']);
227+
.breadcrumbs(['parameterKey'])
228+
.value();
228229

229230
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith(
230231
expect.objectContaining({
@@ -1750,12 +1751,6 @@ describe('Try', () => {
17501751

17511752
expect(awaited).toBe(t);
17521753
});
1753-
1754-
it('AsyncFunction-wrapped Try IS thenable and await works', async () => {
1755-
const t = new Try(async () => 1);
1756-
expect('then' in t).toBe(true);
1757-
await expect(Promise.resolve(t)).resolves.toBe(1);
1758-
});
17591754
});
17601755

17611756
describe('.default() shares exec state with parent', () => {

src/__tests__/all-usecases.test.ts

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -116,31 +116,7 @@ describe('Result methods', () => {
116116
// --- PromiseLike / await behavior --------------------------------------
117117

118118
describe('PromiseLike / await', () => {
119-
it('await async Try resolves to T | undefined', async () => {
120-
const result = await new Try(asyncNoArgs);
121-
expectTypeOf(result).toEqualTypeOf<number | undefined>();
122-
expect(result).toBe(42);
123-
});
124-
125-
it('await async Try.default resolves to T | D', async () => {
126-
const result = await new Try(asyncNoArgs).default('fallback' as const);
127-
expectTypeOf(result).toEqualTypeOf<number | 'fallback'>();
128-
});
129-
130-
it('async .then typechecks', async () => {
131-
const result = await new Try(asyncNoArgs).then((v) => {
132-
expectTypeOf(v).toEqualTypeOf<number | undefined>();
133-
return v ?? 0;
134-
});
135-
expect(result).toBe(42);
136-
});
137-
138-
it('sync .then type is never', () => {
139-
const t = new Try(syncNoArgs);
140-
expectTypeOf(t.then).toEqualTypeOf<never>();
141-
});
142-
143-
it('sync Try has no runtime then (await returns instance as-is)', async () => {
119+
it('Try has no runtime then (await returns instance as-is)', async () => {
144120
const t = new Try(syncNoArgs);
145121
expect((t as unknown as { then?: unknown }).then).toBeUndefined();
146122
const awaited = await (t as unknown as Promise<unknown>);

src/__tests__/coverage-gaps.test.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -266,20 +266,6 @@ describe('coverage gaps', () => {
266266
});
267267
});
268268

269-
describe('Try thenable branch', () => {
270-
it('then(null, reject) covers null onfulfilled branch', async () => {
271-
async function asyncOk() {
272-
return 7;
273-
}
274-
const t = new Try(asyncOk);
275-
const value = await (t as unknown as PromiseLike<number>).then(
276-
null,
277-
() => -1,
278-
);
279-
expect(value).toBe(7);
280-
});
281-
});
282-
283269
describe('extractDoctests untagged unterminated fence', () => {
284270
it('breaks scanning on untagged unterminated fence without throw', () => {
285271
const source = 'intro\n```js\nconst x = 1;\n';

src/__tests__/review-regressions.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,25 +144,25 @@ describe('Regression: multi-CLI review findings', () => {
144144
* `Function.prototype.bind` output (constructor name = 'Function').
145145
* Bound async methods should still be thenable.
146146
*/
147-
describe('R-04 bound async functions are thenable', () => {
148-
it('await new Try(asyncMethod.bind(instance)) executes and resolves', async () => {
147+
describe('R-04 bound async functions resolve via .value()', () => {
148+
it('new Try(asyncMethod.bind(instance)).value() executes and resolves', async () => {
149149
class C {
150150
async run() {
151151
return 42;
152152
}
153153
}
154154
const c = new C();
155155

156-
const result = await new Try(c.run.bind(c));
156+
const result = await new Try(c.run.bind(c)).value();
157157

158158
expect(result).toBe(42);
159159
});
160160

161-
it('Try(arrowReturningPromiseFromAsync) with type declared async should be thenable', async () => {
161+
it('Try(arrowReturningPromiseFromAsync) with type declared async resolves via .value()', async () => {
162162
const asyncFn = async () => 'ok';
163163
const bound = asyncFn.bind(null);
164164

165-
const result = await new Try(bound);
165+
const result = await new Try(bound).value();
166166

167167
expect(result).toBe('ok');
168168
});

src/core/Try.ts

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -159,40 +159,6 @@ export class Try<
159159
this.config = { tags: {} };
160160
this.exec = { state: 'pending', finallyRan: new Set(), breadcrumbsEmitted: new Set() };
161161
this.local = { breadcrumbsAdded: false };
162-
// Only `AsyncFunction`s are thenable: `installThenable()` defines an owned
163-
// `.then` data property so `await new Try(asyncFn)` works without
164-
// triggering execution at probe time. Non-async functions (including
165-
// sync functions that happen to return a Promise) are NOT thenable —
166-
// any thenability probe (Promise.resolve, util.inspect, jest deep-equal,
167-
// Sentry serialization, ...) must never silently invoke the wrapped
168-
// function. Use `.value()` / `.unwrap()` / `.error()` / `.result()`
169-
// (which still handle Promise-returning sync fns via `execute()`).
170-
if (fn.constructor.name === 'AsyncFunction') {
171-
this.installThenable();
172-
}
173-
}
174-
175-
/**
176-
* Install a thenable `.then` method directly on this instance for
177-
* `AsyncFunction`-wrapped Try instances. Defers to `.value()` (never throws;
178-
* returns the configured default on error) and wraps in `Promise.resolve(...)`
179-
* so it also works once the underlying promise has settled.
180-
*/
181-
private installThenable(): void {
182-
const thenFn = (
183-
onfulfilled?: ((value: unknown) => unknown) | null,
184-
onrejected?: ((reason: unknown) => unknown) | null,
185-
): Promise<unknown> =>
186-
Promise.resolve(this.value() as unknown).then(
187-
onfulfilled ?? undefined,
188-
onrejected ?? undefined,
189-
);
190-
Object.defineProperty(this, 'then', {
191-
configurable: true,
192-
enumerable: false,
193-
writable: true,
194-
value: thenFn,
195-
});
196162
}
197163

198164
/**
@@ -905,41 +871,4 @@ export class Try<
905871
return BreadcrumbExtractorUtil.extract(config, this.args, this.config.debug);
906872
}
907873

908-
/**
909-
* Make the Try instance thenable so it can be `await`-ed directly (async only).
910-
* This executes the underlying function with the current configuration and resolves
911-
* with the same result as calling `.value()` (never throws, returns undefined on error).
912-
*
913-
* For sync wrapped functions, `.then` is typed as `never` to prevent misuse —
914-
* use `.value()` / `.unwrap()` directly instead of awaiting.
915-
*
916-
* @example
917-
* ```typescript
918-
* // Direct await - equivalent to .value()
919-
* const user = await new Try(fetchUser, userId)
920-
* .report('Failed to fetch user');
921-
*
922-
* // Can be used in Promise chains
923-
* const result = await new Try(processData, input)
924-
* .default('fallback')
925-
* .then(data => data?.toUpperCase() || 'NO DATA');
926-
*
927-
* // Behaves like .value() - never throws
928-
* const users = await new Try(fetchUsers); // undefined if error
929-
* ```
930-
*/
931-
declare then: IfPromise<
932-
TReturn,
933-
<TResult1 = Awaited<TReturn> | TDefault, TResult2 = never>(
934-
onfulfilled?:
935-
| ((
936-
value: Awaited<TReturn> | TDefault,
937-
) => TResult1 | PromiseLike<TResult1>)
938-
| null,
939-
onrejected?:
940-
| ((reason: unknown) => TResult2 | PromiseLike<TResult2>)
941-
| null,
942-
) => Promise<TResult1 | TResult2>,
943-
never
944-
>;
945874
}

0 commit comments

Comments
 (0)