Skip to content

ADR-004: Tagged Error Handling

Status

Accepted

Context

Error handling in JavaScript/TypeScript has several challenges:

  1. Untyped errors: throw accepts anything, catch gets unknown
  2. Lost context: Stack traces often don’t include business context
  3. Error classification: Hard to distinguish error types programmatically
  4. 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

src/Client.ts
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;
}> {}
src/Contract.ts
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;
}> {}
src/ZkConfig.ts
export class ZkConfigError extends Data.TaggedError('ZkConfigError')<{
readonly cause: unknown;
readonly message: string;
}> {}
src/PrivateState.ts
export class PrivateStateError extends Data.TaggedError('PrivateStateError')<{
readonly cause: unknown;
readonly message: string;
}> {}
src/Wallet.ts
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 failures

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

PropertyTypeDescription
_tagstringDiscriminator for pattern matching
causeunknownOriginal error or value
messagestringHuman-readable description

Clean Stack Traces

We clean Effect internals from stack traces for better debugging:

src/Runtime.ts
function cleanErrorChain(error: Error): Error {
// Remove Effect.ts internal frames
// Preserve original error chain
// Return clean error for Promise API users
}

Consequences

Benefits

  1. Type safety: Errors are part of the type signature
  2. Pattern matching: catchTag enables precise error handling
  3. Context preservation: Original cause is always available
  4. Consistent structure: All errors have _tag, cause, message
  5. Composability: Errors can be mapped and transformed

Tradeoffs

  1. Verbosity: Creating tagged errors requires more code than throw new Error()
  2. Learning curve: Developers must understand tagged unions
  3. 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?

FeaturePlain ErrorData.TaggedError
Type discriminationinstanceof (runtime)_tag (compile-time)
Effect integrationManualBuilt-in catchTag
ExhaustivenessNo compiler helpCompiler checks all tags
ImmutabilityMutableImmutable by default