Skip to content

Commit d15f080

Browse files
stage before error channel
1 parent 672c430 commit d15f080

1 file changed

Lines changed: 244 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
# Design: Type-Safe `onError` Handler
2+
3+
## Problem
4+
5+
The `onError` handler in `TaskDefineConfig` receives `error: unknown`:
6+
7+
```typescript
8+
readonly onError?: (
9+
ctx: TaskContext<S>,
10+
error: unknown, // <-- loses type information
11+
) => Effect.Effect<void, OErr, R>
12+
```
13+
14+
We already have the error types from the other handlers (`EErr` from `onEvent`, `AErr` from `onAlarm`). The error flowing into `onError` will always be one of those types. There's no reason it should be `unknown`.
15+
16+
## Root Cause
17+
18+
The type information is lost in two places:
19+
20+
1. **`TaskDefineConfig`** — has `EErr` and `AErr` as separate type params but doesn't use them in the `onError` signature
21+
2. **`TaskDefinition`** — collapses all error types into a single `Err` union (`EErr | AErr | OErr | GErr`), so the distinction between "errors I catch" and "errors I throw" is erased
22+
23+
## Design
24+
25+
Preserve separate error type parameters through `TaskDefinition` instead of collapsing them into a single `Err`.
26+
27+
### `TaskDefineConfig` (user-facing)
28+
29+
The only change is the `onError` parameter type — `unknown` becomes `EErr | AErr`:
30+
31+
```typescript
32+
export interface TaskDefineConfig<S, E, EErr, AErr, R, OErr = never, GErr = never> {
33+
readonly state: PureSchema<S>
34+
readonly event: PureSchema<E>
35+
readonly onEvent: (ctx: TaskContext<S>, event: E) => Effect.Effect<void, EErr, R>
36+
readonly onAlarm: (ctx: TaskContext<S>) => Effect.Effect<void, AErr, R>
37+
readonly onError?: (
38+
ctx: TaskContext<S>,
39+
error: EErr | AErr, // <-- was `unknown`
40+
) => Effect.Effect<void, OErr, R>
41+
readonly onClientGetState?: (ctx: TaskContext<S>, state: S | null) => Effect.Effect<S | null, GErr, R>
42+
}
43+
```
44+
45+
Same change for `TaskDefineConfigVoid` (replace `unknown` with `EErr | AErr`).
46+
47+
### `TaskDefinition` (internal)
48+
49+
Split the single `Err` param into the individual handler error types:
50+
51+
```typescript
52+
export interface TaskDefinition<S, E, EErr, AErr, R, OErr = never, GErr = never> {
53+
readonly _tag: "TaskDefinition"
54+
readonly state: PureSchema<S>
55+
readonly event: PureSchema<E>
56+
readonly onEvent: (ctx: TaskContext<S>, event: E) => Effect.Effect<void, EErr, R>
57+
readonly onAlarm: (ctx: TaskContext<S>) => Effect.Effect<void, AErr, R>
58+
readonly onError?: (
59+
ctx: TaskContext<S>,
60+
error: EErr | AErr,
61+
) => Effect.Effect<void, OErr, R>
62+
readonly onClientGetState?: (ctx: TaskContext<S>, state: S | null) => Effect.Effect<S | null, GErr, R>
63+
}
64+
```
65+
66+
Add a utility type for extracting the full error union when needed:
67+
68+
```typescript
69+
export type TaskErrors<D> =
70+
D extends TaskDefinition<any, any, infer EErr, infer AErr, any, infer OErr, infer GErr>
71+
? EErr | AErr | OErr | GErr
72+
: never
73+
```
74+
75+
### `Task.define()`
76+
77+
Becomes a direct pass-through — no union collapsing needed:
78+
79+
```typescript
80+
export const Task = {
81+
define<S, E, EErr, AErr, R, OErr = never, GErr = never>(
82+
config: TaskDefineConfig<S, E, EErr, AErr, R, OErr, GErr>,
83+
): TaskDefinition<S, E, EErr, AErr, R, OErr, GErr> {
84+
return {
85+
_tag: "TaskDefinition",
86+
state: config.state,
87+
event: config.event,
88+
onEvent: config.onEvent,
89+
onAlarm: config.onAlarm,
90+
onError: config.onError,
91+
onClientGetState: config.onClientGetState,
92+
}
93+
},
94+
}
95+
```
96+
97+
### `withServices`
98+
99+
Update signature to preserve the split types:
100+
101+
```typescript
102+
export function withServices<S, E, EErr, AErr, R, OErr, GErr>(
103+
definition: TaskDefinition<S, E, EErr, AErr, R, OErr, GErr>,
104+
layer: Layer.Layer<R>,
105+
): TaskDefinition<S, E, EErr, AErr, never, OErr, GErr> {
106+
// body unchanged — just wraps each handler with Effect.provide
107+
}
108+
```
109+
110+
### `buildRegisteredTask` / `registerTask`
111+
112+
Update the generic params from `<S, E, Err>` to `<S, E, EErr, AErr, OErr, GErr>`:
113+
114+
```typescript
115+
function buildRegisteredTask<S, E, EErr, AErr, OErr, GErr>(
116+
definition: TaskDefinition<S, E, EErr, AErr, never, OErr, GErr>,
117+
): RegisteredTask {
118+
// ...
119+
120+
const handleEvent = (...): Effect.Effect<void, TaskValidationError | TaskExecutionError> =>
121+
Effect.gen(function* () {
122+
const event = yield* decodeEvent(rawEvent).pipe(
123+
Effect.mapError((e) => new TaskValidationError({ message: e.message, cause: e })),
124+
)
125+
const ctx = buildTaskContext(storage, alarm, id, name, decodeState, encodeState)
126+
127+
// onEvent fails with EErr. Widen to include PurgeSignal for catchTag.
128+
const withPurge = definition.onEvent(ctx, event).pipe(
129+
Effect.mapError((e): EErr | PurgeSignal => e),
130+
Effect.catchTag("PurgeSignal", () => cleanup(storage, alarm)),
131+
)
132+
// After catchTag, error type is EErr | TaskExecutionError
133+
134+
if (!definition.onError) {
135+
yield* withPurge.pipe(
136+
Effect.mapError((e) => new TaskExecutionError({ cause: e })),
137+
)
138+
return
139+
}
140+
141+
// Effect.catch catches EErr, which is assignable to EErr | AErr
142+
yield* withPurge.pipe(
143+
Effect.catch((error) =>
144+
definition.onError!(ctx, error as EErr | AErr).pipe(
145+
Effect.mapError((e): OErr | PurgeSignal => e),
146+
Effect.catchTag("PurgeSignal", () => cleanup(storage, alarm)),
147+
Effect.mapError((e) => new TaskExecutionError({ cause: e })),
148+
)
149+
),
150+
)
151+
})
152+
153+
// handleAlarm — same pattern, catches AErr which is assignable to EErr | AErr
154+
}
155+
```
156+
157+
**Note on the `as EErr | AErr`**: The `Effect.catch` callback receives `EErr` (the error type from `onEvent`). The `onError` handler expects `EErr | AErr`. Since `EErr` is a subtype of `EErr | AErr`, this is a safe widening. Depending on how `Effect.catch` infers its callback parameter type, TypeScript may need the explicit annotation. If `Effect.catch` preserves the exact error type from the upstream effect, no cast is needed — `EErr` is assignable to `EErr | AErr` naturally. We should verify this during implementation and only add the annotation if the compiler requires it.
158+
159+
### `registerTask` / `registerTaskWithLayer`
160+
161+
```typescript
162+
export function registerTask<S, E, EErr, AErr, OErr, GErr>(
163+
definition: TaskDefinition<S, E, EErr, AErr, never, OErr, GErr>,
164+
): RegisteredTask {
165+
return buildRegisteredTask(definition)
166+
}
167+
168+
export function registerTaskWithLayer<S, E, EErr, AErr, R, OErr, GErr>(
169+
definition: TaskDefinition<S, E, EErr, AErr, R, OErr, GErr>,
170+
layer: Layer.Layer<R>,
171+
): RegisteredTask {
172+
const resolved = withServices(definition, layer)
173+
return buildRegisteredTask(resolved)
174+
}
175+
```
176+
177+
### Cloudflare `createTasks` type helpers
178+
179+
Any type helpers like `EventOf`, `StateOf` that extract types from `TaskDefinition` need their type parameter positions updated to match the new signature.
180+
181+
## What Does NOT Change
182+
183+
- `RegisteredTask` — stays the same (type-erased closures)
184+
- `TaskRunner` — stays the same (works with `RegisteredTask`)
185+
- `TaskContext` — stays the same
186+
- Error classes (`TaskError`, `TaskExecutionError`, etc.) — stay the same
187+
- Storage/Alarm services — stay the same
188+
- Runtime behavior — no changes at all
189+
190+
## User Experience
191+
192+
Before:
193+
```typescript
194+
const myTask = Task.define({
195+
state: MyState,
196+
event: MyEvent,
197+
onEvent: (ctx, event) =>
198+
Effect.gen(function* () {
199+
// can fail with MyEventError
200+
}),
201+
onAlarm: (ctx) =>
202+
Effect.gen(function* () {
203+
// can fail with MyAlarmError
204+
}),
205+
onError: (ctx, error) => {
206+
// error: unknown — must manually narrow
207+
// no autocomplete, no exhaustiveness checking
208+
},
209+
})
210+
```
211+
212+
After:
213+
```typescript
214+
const myTask = Task.define({
215+
state: MyState,
216+
event: MyEvent,
217+
onEvent: (ctx, event) =>
218+
Effect.gen(function* () {
219+
// can fail with MyEventError
220+
}),
221+
onAlarm: (ctx) =>
222+
Effect.gen(function* () {
223+
// can fail with MyAlarmError
224+
}),
225+
onError: (ctx, error) => {
226+
// error: MyEventError | MyAlarmError — fully typed
227+
// can use Effect.catchTag, switch on _tag, exhaustive matching, etc.
228+
},
229+
})
230+
```
231+
232+
## Files to Modify
233+
234+
1. `src/TaskDefinition.ts` — split `Err` into `EErr, AErr, OErr, GErr`; change `onError` param type
235+
2. `src/Task.ts` — update `define()` return type
236+
3. `src/services/TaskRegistry.ts` — update `buildRegisteredTask`, `registerTask`, `registerTaskWithLayer` generics
237+
4. `src/cloudflare/createTasks.ts` — update type helper positions if they reference `TaskDefinition` type params
238+
239+
## Why This Works Without Hacks
240+
241+
- Effect's type system already unions error types naturally via `flatMap`, `gen`, etc.
242+
- The separate type params (`EErr`, `AErr`) are already inferred by TypeScript at the `Task.define()` call site — we just weren't threading them through
243+
- `EErr` is naturally assignable to `EErr | AErr`, so `Effect.catch` in `handleEvent` can pass its caught `EErr` error directly to `onError` which expects `EErr | AErr`
244+
- No type casts, no `as unknown as`, no runtime changes — purely a type-level refactor

0 commit comments

Comments
 (0)