|
| 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