ADR-004: Tagged Error Handling
Status
Accepted
Context
Error handling in JavaScript/TypeScript has several challenges:
- Untyped errors:
throwaccepts anything,catchgetsunknown - Lost context: Stack traces often don’t include business context
- Error classification: Hard to distinguish error types programmatically
- Error composition: Combining errors from different operations is awkward
We needed an error handling strategy that:
- Provides typed, discriminated errors
- Preserves original error context
- Enables pattern matching on error types
- Works well with Effect.ts
Decision
We use Effect’s Data.TaggedError pattern for all SDK errors.
Error Definition Pattern
export class ClientError extends Data.TaggedError('ClientError')<{ readonly cause: unknown; readonly message: string;}> {}
export class TxTimeoutError extends Data.TaggedError('TxTimeoutError')<{ readonly txHash: string; readonly timeout: number; readonly message: string;}> {}export class ContractError extends Data.TaggedError('ContractError')<{ readonly cause: unknown; readonly message: string;}> {}
export class TxTimeoutError extends Data.TaggedError('TxTimeoutError')<{ readonly operation: string; readonly message: string;}> {}export class ZkConfigError extends Data.TaggedError('ZkConfigError')<{ readonly cause: unknown; readonly message: string;}> {}export class PrivateStateError extends Data.TaggedError('PrivateStateError')<{ readonly cause: unknown; readonly message: string;}> {}export class WalletError extends Data.TaggedError('WalletError')<{ readonly cause: unknown; readonly message: string;}> {}Error Hierarchy
SDK Errors├── ClientError # Client creation/configuration failures├── Client.TxTimeoutError # waitForTx exceeded timeout (has txHash)├── ContractError # Contract deployment/call failures├── Contract.TxTimeoutError # Deploy/join exceeded timeout (has operation)├── WalletError # Wallet initialization/sync failures├── ZkConfigError # ZK configuration loading failures└── PrivateStateError # Private state storage failuresError Creation
Errors wrap the original cause and add a human-readable message:
function loadContractEffect( options: LoadContractOptions,): Effect.Effect<LoadedContract, ClientError> { return Effect.try({ try: () => { /* ... */ }, catch: (cause) => new ClientError({ cause, message: `Failed to load contract: ${cause instanceof Error ? cause.message : String(cause)}`, }), });}Error Handling with Effect
Catch Specific Error Types
const program = deployed.effect.call('increment').pipe( Effect.catchTag('ContractError', (error) => { console.error('Contract call failed:', error.message); return Effect.succeed({ status: 'failed' }); }));Catch All Errors
const program = Midday.Client.effect.create(config).pipe( Effect.catchAll((error) => { // error is typed as ClientError console.error('Client creation failed:', error.message); return Effect.fail(error); }));Transform Errors
const walletContext = yield* Wallet.effect.init(seed, networkConfig).pipe( Effect.mapError((e) => new ClientError({ cause: e, message: `Failed to initialize wallet: ${e.message}`, })),);Error Properties
All errors have consistent properties:
| Property | Type | Description |
|---|---|---|
_tag | string | Discriminator for pattern matching |
cause | unknown | Original error or value |
message | string | Human-readable description |
Clean Stack Traces
We clean Effect internals from stack traces for better debugging:
function cleanErrorChain(error: Error): Error { // Remove Effect.ts internal frames // Preserve original error chain // Return clean error for Promise API users}Consequences
Benefits
- Type safety: Errors are part of the type signature
- Pattern matching:
catchTagenables precise error handling - Context preservation: Original cause is always available
- Consistent structure: All errors have
_tag,cause,message - Composability: Errors can be mapped and transformed
Tradeoffs
- Verbosity: Creating tagged errors requires more code than
throw new Error() - Learning curve: Developers must understand tagged unions
- Effect dependency: TaggedError is an Effect.ts concept
Error Handling Examples
Promise API (Errors as Exceptions)
try { const client = await Midday.Client.create(config);} catch (error) { if (error instanceof Midday.Client.ClientError) { console.error('Client error:', error.message); console.error('Cause:', error.cause); }}Effect API (Typed Errors)
const program = Effect.gen(function* () { const client = yield* Midday.Client.effect.create(config); return client;}).pipe( Effect.catchTag('ClientError', (error) => { // error is typed as ClientError Effect.logError(`Failed: ${error.message}`); return Effect.fail(error); }));Why Not Plain Error Classes?
| Feature | Plain Error | Data.TaggedError |
|---|---|---|
| Type discrimination | instanceof (runtime) | _tag (compile-time) |
| Effect integration | Manual | Built-in catchTag |
| Exhaustiveness | No compiler help | Compiler checks all tags |
| Immutability | Mutable | Immutable by default |