Skip to content

Commit 6c0cfde

Browse files
Change driveGen to throw errors instead of returning UnhandledException (#30)
This pull request changes the error handling behavior in the generator execution system from wrapping exceptions in `UnhandledException` to throwing the original errors directly. **Key Changes:** - **Generator execution now throws original errors**: The `driveGen` function no longer catches and wraps exceptions in `UnhandledException`. Instead, it allows the original errors to propagate naturally. - **Preserved control error handling**: Special error types like `TimeoutError`, `CancellationError`, and `Panic` continue to be preserved and thrown as-is, maintaining their specific error semantics. - **Simplified async error propagation**: Rejected promises in the async execution path now reject with their original reasons rather than being wrapped in `UnhandledException`. - **Updated type assertions**: Test type assertions now include `UnhandledException` in the expected union types to reflect that it can still be returned in certain error scenarios. - **Comprehensive test updates**: All tests have been updated to expect thrown errors using try-catch blocks instead of checking return values, including tests for factory errors, generator body errors, promise rejections, and non-Error thrown values. The change makes error handling more predictable by preserving the original error types and stack traces while maintaining backward compatibility for control flow errors.
1 parent beb4d28 commit 6c0cfde

File tree

4 files changed

+195
-161
lines changed

4 files changed

+195
-161
lines changed

src/__tests__/types.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,10 @@ describe("type inference", () => {
487487
})
488488

489489
type _assert = Expect<
490-
Equal<typeof result, Promise<string | UserNotFound | PermissionDenied | ProjectNotFound>>
490+
Equal<
491+
typeof result,
492+
Promise<string | UserNotFound | PermissionDenied | ProjectNotFound | UnhandledException>
493+
>
491494
>
492495
})
493496
})

src/lib/__tests__/gen.test.ts

Lines changed: 171 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "bun:test"
2-
import { CancellationError, Panic, TimeoutError, UnhandledException } from "../errors"
2+
import { CancellationError, Panic, TimeoutError } from "../errors"
33
import { driveGen } from "../gen"
44

55
class UserNotFound extends Error {}
@@ -62,68 +62,119 @@ describe("driveGen", () => {
6262
expect(didRunAfterError).toBe(false)
6363
})
6464

65-
it("propagates rejected yielded promise", async () => {
66-
const result = await driveGen(function* (use) {
67-
const value = yield* use(Promise.reject<unknown>(new Error("boom")))
68-
return value
69-
})
70-
71-
expect(result).toBeInstanceOf(UnhandledException)
72-
expect((result as UnhandledException).cause).toBeInstanceOf(Error)
73-
expect(((result as UnhandledException).cause as Error).message).toBe("boom")
65+
it("rejects with the original reason when a yielded promise rejects", async () => {
66+
try {
67+
await driveGen(function* (use) {
68+
const value = yield* use(Promise.reject<unknown>(new Error("boom")))
69+
return value
70+
})
71+
expect.unreachable("should have thrown")
72+
} catch (error) {
73+
expect(error).toBeInstanceOf(Error)
74+
expect((error as Error).message).toBe("boom")
75+
}
7476
})
7577

7678
it("preserves TimeoutError from a rejected yielded promise", async () => {
7779
const timeout = new TimeoutError("timed out")
7880

79-
const result = await driveGen(function* (use) {
80-
const value = yield* use(Promise.reject<unknown>(timeout))
81-
return value
82-
})
81+
try {
82+
await driveGen(function* (use) {
83+
const value = yield* use(Promise.reject<unknown>(timeout))
84+
return value
85+
})
86+
expect.unreachable("should have thrown")
87+
} catch (error) {
88+
expect(error).toBe(timeout)
89+
}
90+
})
8391

84-
expect(result).toBe(timeout)
92+
it("runs finally blocks when the first yielded promise rejects", async () => {
93+
let finalized = false
94+
95+
try {
96+
await driveGen(function* (use) {
97+
try {
98+
yield* use(Promise.reject<unknown>(new Error("boom")))
99+
} finally {
100+
finalized = true
101+
}
102+
})
103+
expect.unreachable("should have thrown")
104+
} catch (error) {
105+
expect(error).toBeInstanceOf(Error)
106+
expect((error as Error).message).toBe("boom")
107+
}
108+
109+
expect(finalized).toBe(true)
85110
})
86111

87-
it("returns error when factory throws", () => {
88-
const result = driveGen(() => {
89-
throw new Error("factory failed")
112+
it("lets the generator catch a rejected yielded promise and recover", async () => {
113+
const result = await driveGen(function* (use) {
114+
try {
115+
yield* use(Promise.reject<unknown>(new Error("boom")))
116+
} catch (error) {
117+
expect(error).toBeInstanceOf(Error)
118+
expect((error as Error).message).toBe("boom")
119+
return 42
120+
}
121+
122+
return 0
90123
})
91124

92-
expect(result).toBeInstanceOf(UnhandledException)
93-
expect((result as UnhandledException).cause).toBeInstanceOf(Error)
94-
expect(((result as UnhandledException).cause as Error).message).toBe("factory failed")
125+
expect(result).toBe(42)
126+
})
127+
128+
it("throws the original error when factory throws", () => {
129+
try {
130+
driveGen(() => {
131+
throw new Error("factory failed")
132+
})
133+
expect.unreachable("should have thrown")
134+
} catch (error) {
135+
expect(error).toBeInstanceOf(Error)
136+
expect((error as Error).message).toBe("factory failed")
137+
}
95138
})
96139

97140
it("preserves Panic when factory throws a control error", () => {
98141
const panic = new Panic("FLOW_NO_EXIT")
99142

100-
const result = driveGen(() => {
101-
throw panic
102-
})
103-
104-
expect(result).toBe(panic)
143+
try {
144+
driveGen(() => {
145+
throw panic
146+
})
147+
expect.unreachable("should have thrown")
148+
} catch (error) {
149+
expect(error).toBe(panic)
150+
}
105151
})
106152

107-
it("returns error when generator body throws after yield", () => {
108-
const result = driveGen(function* (use) {
109-
void (yield* use(1))
110-
throw new Error("generator failed")
111-
})
112-
113-
expect(result).toBeInstanceOf(UnhandledException)
114-
expect((result as UnhandledException).cause).toBeInstanceOf(Error)
115-
expect(((result as UnhandledException).cause as Error).message).toBe("generator failed")
153+
it("throws the original error when generator body throws after yield", () => {
154+
try {
155+
driveGen(function* (use) {
156+
void (yield* use(1))
157+
throw new Error("generator failed")
158+
})
159+
expect.unreachable("should have thrown")
160+
} catch (error) {
161+
expect(error).toBeInstanceOf(Error)
162+
expect((error as Error).message).toBe("generator failed")
163+
}
116164
})
117165

118166
it("preserves Panic when generator body throws after a sync yield", () => {
119167
const panic = new Panic("FLOW_NO_EXIT")
120168

121-
const result = driveGen<number, number | Panic>(function* (use) {
122-
void (yield* use(1))
123-
throw panic
124-
})
125-
126-
expect(result).toBe(panic)
169+
try {
170+
driveGen<number, number | Panic>(function* (use) {
171+
void (yield* use(1))
172+
throw panic
173+
})
174+
expect.unreachable("should have thrown")
175+
} catch (error) {
176+
expect(error).toBe(panic)
177+
}
127178
})
128179

129180
it("returns explicit error values without throwing", () => {
@@ -149,68 +200,105 @@ describe("driveGen", () => {
149200
expect(resolved.message).toBe("async return")
150201
})
151202

152-
it("wraps non-Error thrown value in UnhandledException", () => {
153-
const result = driveGen(() => {
154-
throw "string error"
155-
})
156-
157-
expect(result).toBeInstanceOf(UnhandledException)
158-
expect((result as UnhandledException).cause).toBe("string error")
203+
it("throws raw non-Error values without wrapping", () => {
204+
try {
205+
driveGen(() => {
206+
throw "string error"
207+
})
208+
expect.unreachable("should have thrown")
209+
} catch (error) {
210+
expect(error).toBe("string error")
211+
}
159212
})
160213

161-
it("wraps thrown error in UnhandledException in async path when generator throws after yield", async () => {
162-
const result = await driveGen(function* (use) {
163-
void (yield* use(Promise.resolve(1)))
164-
throw new Error("async throw")
165-
})
166-
167-
expect(result).toBeInstanceOf(UnhandledException)
168-
expect((result as UnhandledException).cause).toBeInstanceOf(Error)
214+
it("rejects with the original error when the generator throws after entering async path", async () => {
215+
try {
216+
await driveGen(function* (use) {
217+
void (yield* use(Promise.resolve(1)))
218+
throw new Error("async throw")
219+
})
220+
expect.unreachable("should have thrown")
221+
} catch (error) {
222+
expect(error).toBeInstanceOf(Error)
223+
expect((error as Error).message).toBe("async throw")
224+
}
169225
})
170226

171227
it("preserves CancellationError when generator throws after entering async path", async () => {
172228
const cancellation = new CancellationError("cancelled")
173229

174-
const result = await driveGen<Promise<number>, Promise<number | CancellationError>>(
175-
function* (use) {
230+
try {
231+
await driveGen<Promise<number>, Promise<number | CancellationError>>(function* (use) {
176232
void (yield* use(Promise.resolve(1)))
177233
throw cancellation
178-
}
179-
)
180-
181-
expect(result).toBe(cancellation)
234+
})
235+
expect.unreachable("should have thrown")
236+
} catch (error) {
237+
expect(error).toBe(cancellation)
238+
}
182239
})
183240

184-
it("wraps rejection of final returned promise in async path", async () => {
185-
const result = await driveGen(function* (use) {
186-
void (yield* use(Promise.resolve(1)))
187-
return Promise.reject(new Error("final reject"))
188-
})
189-
190-
expect(result).toBeInstanceOf(UnhandledException)
191-
expect((result as UnhandledException).cause).toBeInstanceOf(Error)
241+
it("rejects with the original error when the final returned promise rejects", async () => {
242+
try {
243+
await driveGen(function* (use) {
244+
void (yield* use(Promise.resolve(1)))
245+
return Promise.reject(new Error("final reject"))
246+
})
247+
expect.unreachable("should have thrown")
248+
} catch (error) {
249+
expect(error).toBeInstanceOf(Error)
250+
expect((error as Error).message).toBe("final reject")
251+
}
192252
})
193253

194254
it("preserves TimeoutError from a rejected final returned promise in async path", async () => {
195255
const timeout = new TimeoutError("timed out")
196256

197-
const result = await driveGen<Promise<number>, Promise<number | TimeoutError>>(function* (use) {
198-
void (yield* use(Promise.resolve(1)))
199-
return Promise.reject(timeout)
200-
})
257+
try {
258+
await driveGen<Promise<number>, Promise<number | TimeoutError>>(function* (use) {
259+
void (yield* use(Promise.resolve(1)))
260+
return Promise.reject(timeout)
261+
})
262+
expect.unreachable("should have thrown")
263+
} catch (error) {
264+
expect(error).toBe(timeout)
265+
}
266+
})
201267

202-
expect(result).toBe(timeout)
268+
it("rejects with the original reason when a later async yield rejects", async () => {
269+
try {
270+
await driveGen(function* (use) {
271+
void (yield* use(Promise.resolve(1)))
272+
const value = yield* use(Promise.reject<unknown>(new Error("second reject")))
273+
return value
274+
})
275+
expect.unreachable("should have thrown")
276+
} catch (error) {
277+
expect(error).toBeInstanceOf(Error)
278+
expect((error as Error).message).toBe("second reject")
279+
}
203280
})
204281

205-
it("wraps rejection of second async yield", async () => {
206-
const result = await driveGen(function* (use) {
207-
void (yield* use(Promise.resolve(1)))
208-
const value = yield* use(Promise.reject<unknown>(new Error("second reject")))
209-
return value
210-
})
282+
it("runs finally blocks when a later async yield rejects", async () => {
283+
let finalized = false
284+
285+
try {
286+
await driveGen(function* (use) {
287+
void (yield* use(Promise.resolve(1)))
211288

212-
expect(result).toBeInstanceOf(UnhandledException)
213-
expect((result as UnhandledException).cause).toBeInstanceOf(Error)
289+
try {
290+
yield* use(Promise.reject<unknown>(new Error("second reject")))
291+
} finally {
292+
finalized = true
293+
}
294+
})
295+
expect.unreachable("should have thrown")
296+
} catch (error) {
297+
expect(error).toBeInstanceOf(Error)
298+
expect((error as Error).message).toBe("second reject")
299+
}
300+
301+
expect(finalized).toBe(true)
214302
})
215303

216304
it("handles sync yield after entering async path", async () => {

src/lib/executors/__tests__/base.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ describe("BaseExecution", () => {
8686
using execution = new TestExecution(
8787
{
8888
wraps: [
89-
(ctx, next) => {
89+
(_, next) => {
9090
wrapCalls += 1
9191
return next()
9292
},

0 commit comments

Comments
 (0)