Skip to content

Commit 4a7c447

Browse files
committed
Add async support to run method with error mapping
1 parent bb72fd6 commit 4a7c447

File tree

8 files changed

+315
-21
lines changed

8 files changed

+315
-21
lines changed

.context/api-proposal.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,54 @@ const value = try$
2020
const result = try$.run(() => myFn())
2121
```
2222

23+
Sync example with multiple error variants:
24+
25+
```ts
26+
class InvalidInputError extends Error {}
27+
class PermissionDeniedError extends Error {}
28+
29+
const syncResult = try$.run({
30+
try: () => readLocalConfig(),
31+
catch: (error): InvalidInputError | PermissionDeniedError => {
32+
if (error instanceof SyntaxError) {
33+
return new InvalidInputError("Config file is malformed")
34+
}
35+
36+
return new PermissionDeniedError("Config file is not readable")
37+
},
38+
})
39+
40+
// syncResult is Config | InvalidInputError | PermissionDeniedError
41+
```
42+
43+
Async example with multiple error variants:
44+
45+
```ts
46+
class NetworkError extends Error {}
47+
class RemoteServiceError extends Error {}
48+
49+
const asyncResult = await try$.run({
50+
try: async (ctx) => {
51+
const res = await fetch("/api/profile", { signal: ctx.signal })
52+
53+
if (!res.ok) {
54+
throw new Error(`Request failed: ${res.status}`)
55+
}
56+
57+
return (await res.json()) as Profile
58+
},
59+
catch: async (error): Promise<NetworkError | RemoteServiceError> => {
60+
if (error instanceof TypeError) {
61+
return new NetworkError("Network request failed")
62+
}
63+
64+
return new RemoteServiceError("Profile service returned an error")
65+
},
66+
})
67+
68+
// asyncResult is Profile | NetworkError | RemoteServiceError
69+
```
70+
2371
Retries can be very specific:
2472

2573
```ts

.context/plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Goal: implement `hardtry` incrementally with behavior locked by
77

88
- [x] Phase 0 - Foundation
99
- [x] Phase 1 - `run` (sync)
10-
- [ ] Phase 2 - `run` (async)
10+
- [x] Phase 2 - `run` (async)
1111
- [ ] Phase 3 - `retryOptions` + `retry`
1212
- [ ] Phase 4 - `timeout` (v1 total scope only)
1313
- [ ] Phase 5 - `.signal(...)` cancellation

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,8 @@ This project was built with [`pastry`](https://github.com/adelrodriguez/pastry)
1515
- Run `bun changeset --empty` to create a new empty changeset file.
1616
- Never make a major version bump unless the user requests it.
1717
- If a breaking change is being made, and we are on v1.0.0 or higher, alert the user.
18+
19+
## TypeScript Style
20+
21+
- Prefer type inference whenever possible.
22+
- Do not add explicit return types unless required by tooling, declaration emit, or a public API contract.

src/__tests__/index.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, expect, it } from "bun:test"
22
import { Panic, UnhandledException, run } from "../index"
33

4+
class InvalidInputError extends Error {}
5+
class PermissionDeniedError extends Error {}
6+
class NetworkError extends Error {}
7+
class RemoteServiceError extends Error {}
8+
49
describe("run sync", () => {
510
it("returns value when tryFn succeeds", () => {
611
const value = run(() => 42)
@@ -39,4 +44,121 @@ describe("run sync", () => {
3944
})
4045
).toThrow(Panic)
4146
})
47+
48+
it("supports multiple mapped error variants in sync object form", () => {
49+
const invalidInput = run({
50+
catch: (error) => {
51+
if (error instanceof SyntaxError) {
52+
return new InvalidInputError("invalid")
53+
}
54+
55+
return new PermissionDeniedError("denied")
56+
},
57+
try: () => {
58+
throw new SyntaxError("bad input")
59+
},
60+
})
61+
62+
const permissionDenied = run({
63+
catch: (error) => {
64+
if (error instanceof SyntaxError) {
65+
return new InvalidInputError("invalid")
66+
}
67+
68+
return new PermissionDeniedError("denied")
69+
},
70+
try: () => {
71+
throw new Error("no access")
72+
},
73+
})
74+
75+
expect(invalidInput).toBeInstanceOf(InvalidInputError)
76+
expect(permissionDenied).toBeInstanceOf(PermissionDeniedError)
77+
})
78+
})
79+
80+
describe("run async", () => {
81+
it("returns promise value when tryFn is async", async () => {
82+
const result = run(async () => {
83+
await Promise.resolve()
84+
85+
return 42
86+
})
87+
88+
expect(await result).toBe(42)
89+
})
90+
91+
it("returns UnhandledException when async function form rejects", async () => {
92+
const result = run(async () => {
93+
await Promise.resolve()
94+
throw new Error("boom")
95+
})
96+
97+
expect(await result).toBeInstanceOf(UnhandledException)
98+
})
99+
100+
it("maps async object form rejections through catch", async () => {
101+
const result = run({
102+
catch: () => "mapped",
103+
try: async () => {
104+
await Promise.resolve()
105+
throw new Error("boom")
106+
},
107+
})
108+
109+
expect(await result).toBe("mapped")
110+
})
111+
112+
it("throws Panic when async catch rejects", async () => {
113+
const result = run({
114+
catch: async () => {
115+
await Promise.resolve()
116+
throw new Error("catch failed")
117+
},
118+
try: async () => {
119+
await Promise.resolve()
120+
throw new Error("boom")
121+
},
122+
})
123+
124+
try {
125+
await result
126+
throw new Error("Expected Panic rejection")
127+
} catch (error) {
128+
expect(error).toBeInstanceOf(Panic)
129+
}
130+
})
131+
132+
it("supports multiple mapped error variants in async object form", async () => {
133+
const networkError = await run({
134+
catch: (error): NetworkError | RemoteServiceError => {
135+
if (error instanceof TypeError) {
136+
return new NetworkError("network")
137+
}
138+
139+
return new RemoteServiceError("remote")
140+
},
141+
try: async () => {
142+
await Promise.resolve()
143+
throw new TypeError("fetch failed")
144+
},
145+
})
146+
147+
const remoteServiceError = await run({
148+
catch: (error) => {
149+
if (error instanceof TypeError) {
150+
return new NetworkError("network")
151+
}
152+
153+
return new RemoteServiceError("remote")
154+
},
155+
try: async () => {
156+
await Promise.resolve()
157+
throw new Error("500")
158+
},
159+
})
160+
161+
expect(networkError).toBeInstanceOf(NetworkError)
162+
expect(remoteServiceError).toBeInstanceOf(RemoteServiceError)
163+
})
42164
})

src/lib/__tests__/runner.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,63 @@ describe("executeRun", () => {
4747
).toThrow(Panic)
4848
})
4949

50+
it("returns resolved value when function form is async", async () => {
51+
const result = executeRun({}, async () => {
52+
await Promise.resolve()
53+
54+
return "ok" as const
55+
})
56+
57+
expect(await result).toBe("ok")
58+
})
59+
60+
it("maps rejected value to UnhandledException in async function form", async () => {
61+
const result = executeRun({}, async () => {
62+
await Promise.resolve()
63+
throw new Error("boom")
64+
})
65+
66+
expect(await result).toBeInstanceOf(UnhandledException)
67+
})
68+
69+
it("maps async try rejection through catch in object form", async () => {
70+
const result = executeRun(
71+
{},
72+
{
73+
catch: () => "mapped",
74+
try: async () => {
75+
await Promise.resolve()
76+
throw new Error("boom")
77+
},
78+
}
79+
)
80+
81+
expect(await result).toBe("mapped")
82+
})
83+
84+
it("throws Panic when async catch rejects", async () => {
85+
const result = executeRun(
86+
{},
87+
{
88+
catch: async () => {
89+
await Promise.resolve()
90+
throw new Error("catch failed")
91+
},
92+
try: async () => {
93+
await Promise.resolve()
94+
throw new Error("boom")
95+
},
96+
}
97+
)
98+
99+
try {
100+
await result
101+
throw new Error("Expected Panic rejection")
102+
} catch (error) {
103+
expect(error).toBeInstanceOf(Panic)
104+
}
105+
})
106+
50107
it("passes normalized context to try", () => {
51108
const result = executeRun(
52109
{

src/lib/builder.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import type { UnhandledException } from "./errors"
22
import type {
3+
AsyncRunCatchFn,
4+
AsyncRunTryFn,
35
BuilderConfig,
46
RetryPolicy,
5-
RunTryFn,
6-
RunWithCatchOptions,
7+
RunCatchFn,
8+
RunInput,
9+
SyncRunCatchFn,
10+
SyncRunTryFn,
711
TaskMap,
812
TimeoutOptions,
913
WrapFn,
@@ -51,10 +55,16 @@ export class TryBuilder {
5155
})
5256
}
5357

54-
run<T>(tryFn: RunTryFn<T>): T | UnhandledException
55-
run<T, E>(options: RunWithCatchOptions<T, E>): T | E
58+
run<T>(tryFn: SyncRunTryFn<T>): T | UnhandledException
59+
run<T>(tryFn: AsyncRunTryFn<T>): Promise<T | UnhandledException>
60+
run<T, E>(options: { try: SyncRunTryFn<T>; catch: SyncRunCatchFn<E> }): T | E
61+
run<T, E>(
62+
options:
63+
| { try: SyncRunTryFn<T>; catch: AsyncRunCatchFn<E> }
64+
| { try: AsyncRunTryFn<T>; catch: RunCatchFn<E> }
65+
): Promise<T | E>
5666

57-
run<T, E>(input: RunTryFn<T> | RunWithCatchOptions<T, E>) {
67+
run<T, E>(input: RunInput<T, E>) {
5868
return executeRun(this.#config, input)
5969
}
6070

src/lib/runner.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,80 @@
1-
import type { BuilderConfig, RunInput, RunTryFn, RunWithCatchOptions } from "./types"
1+
import type {
2+
AsyncRunCatchFn,
3+
AsyncRunTryFn,
4+
BuilderConfig,
5+
RunInput,
6+
RunCatchFn,
7+
SyncRunCatchFn,
8+
SyncRunTryFn,
9+
} from "./types"
210
import { createContext } from "./context"
311
import { Panic, UnhandledException } from "./errors"
412

5-
export function executeRun<T>(config: BuilderConfig, input: RunTryFn<T>): T | UnhandledException
6-
export function executeRun<T, E>(config: BuilderConfig, input: RunWithCatchOptions<T, E>): T | E
13+
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
14+
return value instanceof Promise
15+
}
16+
17+
function executeCatch<E>(catchFn: RunCatchFn<E>, error: unknown): E | Promise<E> {
18+
try {
19+
const mapped = catchFn(error)
20+
21+
if (isPromiseLike(mapped)) {
22+
return mapped.catch((catchError: unknown) => {
23+
throw new Panic({ cause: catchError })
24+
})
25+
}
26+
27+
return mapped
28+
} catch (catchError) {
29+
throw new Panic({ cause: catchError })
30+
}
31+
}
32+
33+
export function executeRun<T>(config: BuilderConfig, input: SyncRunTryFn<T>): T | UnhandledException
34+
export function executeRun<T>(
35+
config: BuilderConfig,
36+
input: AsyncRunTryFn<T>
37+
): Promise<T | UnhandledException>
38+
export function executeRun<T, E>(
39+
config: BuilderConfig,
40+
input: { try: SyncRunTryFn<T>; catch: SyncRunCatchFn<E> }
41+
): T | E
42+
export function executeRun<T, E>(
43+
config: BuilderConfig,
44+
input:
45+
| { try: SyncRunTryFn<T>; catch: AsyncRunCatchFn<E> }
46+
| { try: AsyncRunTryFn<T>; catch: RunCatchFn<E> }
47+
): Promise<T | E>
748
export function executeRun<T, E>(
849
config: BuilderConfig,
950
input: RunInput<T, E>
10-
): T | E | UnhandledException
51+
): T | E | UnhandledException | Promise<T | E | UnhandledException>
1152
export function executeRun<T, E>(config: BuilderConfig, input: RunInput<T, E>) {
1253
const ctx = createContext(config)
1354

1455
if (typeof input === "function") {
1556
try {
16-
return input(ctx)
57+
const result = input(ctx)
58+
59+
if (isPromiseLike(result)) {
60+
return result.catch((error: unknown) => new UnhandledException({ cause: error }))
61+
}
62+
63+
return result
1764
} catch (error) {
1865
return new UnhandledException({ cause: error })
1966
}
2067
}
2168

2269
try {
23-
return input.try(ctx)
24-
} catch (error) {
25-
try {
26-
return input.catch(error)
27-
} catch (catchError) {
28-
throw new Panic({ cause: catchError })
70+
const result = input.try(ctx)
71+
72+
if (isPromiseLike(result)) {
73+
return result.catch((error: unknown) => executeCatch(input.catch, error))
2974
}
75+
76+
return result
77+
} catch (error) {
78+
return executeCatch(input.catch, error)
3079
}
3180
}

0 commit comments

Comments
 (0)