| name | effect-patterns-error-handling |
| description | Effect-TS patterns for Error Handling. Use when working with error handling in Effect-TS applications. |
Effect-TS Patterns: Error Handling
This skill provides 3 curated Effect-TS patterns for error handling.
Use this skill when working on tasks related to:
- error handling
- Best practices in Effect-TS applications
- Real-world patterns and solutions
🟡 Intermediate Patterns
Error Handling Pattern 1: Accumulating Multiple Errors
Rule: Use error accumulation to report all problems at once rather than failing early, critical for validation and batch operations.
Good Example:
This example demonstrates error accumulation patterns.
import { Effect, Data, Cause } from "effect";
interface ValidationError {
field: string;
message: string;
value?: unknown;
}
interface ProcessingResult<T> {
successes: T[];
errors: ValidationError[];
}
const program = Effect.gen(function* () {
console.log(`\n[ERROR ACCUMULATION] Collecting multiple errors\n`);
interface FormData {
name: string;
email: string;
age: number;
phone: string;
}
const validateForm = (data: FormData): ValidationError[] => {
const errors: ValidationError[] = [];
if (!data.name || data.name.trim().length === 0) {
errors.push({
field: "name",
message: "Name is required",
value: data.name,
});
} else if (data.name.length < 2) {
errors.push({
field: "name",
message: "Name must be at least 2 characters",
value: data.name,
});
}
if (!data.email) {
errors.push({
field: "email",
message: "Email is required",
value: data.email,
});
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.push({
field: "email",
message: "Email format invalid",
value: data.email,
});
}
if (data.age < 0 || data.age > 150) {
errors.push({
field: "age",
message: "Age must be between 0 and 150",
value: data.age,
});
}
if (data.phone && !/^\d{3}-\d{3}-\d{4}$/.test(data.phone)) {
errors.push({
field: "phone",
message: "Phone must be in format XXX-XXX-XXXX",
value: data.phone,
});
}
return errors;
};
console.log(`[1] Form validation with multiple errors:\n`);
const invalidForm: FormData = {
name: "",
email: "not-an-email",
age: 200,
phone: "invalid",
};
const validationErrors = validateForm(invalidForm);
yield* Effect.log(`[VALIDATION] Found ${validationErrors.length} errors:\n`);
for (const error of validationErrors) {
yield* Effect.log(` ✗ ${error.field}: ${error.message}`);
}
console.log(`\n[2] Batch processing (accumulate successes and failures):\n`);
interface Record {
id: string;
data: string;
}
const processRecord = (record: Record): Result<string> => {
if (record.id.length === 0) {
return { success: false, error: "Missing ID" };
}
if (record.data.includes("ERROR")) {
return { success: false, error: "Invalid data" };
}
return { success: true, value: `processed-${record.id}` };
};
interface Result<T> {
success: boolean;
value?: T;
error?: string;
}
const records: Record[] = [
{ id: "rec1", data: "ok" },
{ id: "", data: "ok" },
{ id: "rec3", data: "ok" },
{ id: "rec4", data: "ERROR" },
{ id: "rec5", data: "ok" },
];
const results: ProcessingResult<string> = {
successes: [],
errors: [],
};
for (const record of records) {
const result = processRecord(record);
if (result.success) {
results.successes.push(result.value!);
} else {
results.errors.push({
field: record.id || "unknown",
message: result.error!,
});
}
}
yield* Effect.log(
`[BATCH] Processed ${records.length} records`
);
yield* Effect.log(`[BATCH] ✓ ${results.successes.length} succeeded`);
yield* Effect.log(`[BATCH] ✗ ${results.errors.length} failed\n`);
for (const success of results.successes) {
yield* Effect.log(` ✓ ${success}`);
}
for (const error of results.errors) {
yield* Effect.log(` ✗ [${error.field}] ${error.message}`);
}
console.log(`\n[3] Multi-step validation (all checks run):\n`);
interface ServiceHealth {
diskSpace: boolean;
memory: boolean;
network: boolean;
database: boolean;
}
const diagnostics: ValidationError[] = [];
const diskFree = 50;
if (diskFree < 100) {
diagnostics.push({
field: "disk-space",
message: `Only ${diskFree}MB free (need 100MB)`,
value: diskFree,
});
}
const memUsage = 95;
if (memUsage > 85) {
diagnostics.push({
field: "memory",
message: `Using ${memUsage}% (threshold: 85%)`,
value: memUsage,
});
}
const latency = 500;
if (latency > 200) {
diagnostics.push({
field: "network",
message: `Latency ${latency}ms (threshold: 200ms)`,
value: latency,
});
}
const dbConnections = 95;
const dbMax = 100;
if (dbConnections > dbMax * 0.8) {
diagnostics.push({
field: "database",
message: `${dbConnections}/${dbMax} connections (80% threshold)`,
value: dbConnections,
});
}
if (diagnostics.length === 0) {
yield* Effect.log(`[HEALTH] ✓ All systems normal\n`);
} else {
yield* Effect.log(
`[HEALTH] ✗ ${diagnostics.length} issue(s) detected:\n`
);
for (const diag of diagnostics) {
yield* Effect.log(` ⚠ ${diag.field}: ${diag.message}`);
}
}
console.log(`\n[4] Error collection for retry strategy:\n`);
interface ErrorWithContext {
operation: string;
error: string;
retryable: boolean;
timestamp: Date;
}
const operationErrors: ErrorWithContext[] = [];
const operations = [
{ name: "fetch-config", fail: false },
{ name: "connect-db", fail: true },
{ name: "load-cache", fail: true },
{ name: "start-server", fail: false },
];
for (const op of operations) {
if (op.fail) {
operationErrors.push({
operation: op.name,
error: "Operation failed",
retryable: op.name !== "fetch-config",
timestamp: new Date(),
});
}
}
yield* Effect.log(`[OPERATIONS] ${operationErrors.length} errors:\n`);
for (const err of operationErrors) {
const status = err.retryable ? "🔄 retryable" : "❌ non-retryable";
yield* Effect.log(` ${status}: ${err.operation}`);
}
if (operationErrors.every((e) => e.retryable)) {
yield* Effect.log(`\n[DECISION] All errors retryable, will retry\n`);
} else {
yield* Effect.log(`\n[DECISION] Some non-retryable errors, manual intervention needed\n`);
}
});
Effect.runPromise(program);
Rationale:
Error accumulation strategies:
- Collect errors: Gather all failures before reporting
- Fail late: Continue processing despite errors
- Contextual errors: Keep error location/operation info
- Error summary: Aggregate for reporting
- Partial success: Return valid results + errors
Pattern: Use Cause aggregation, Result types, or custom error structures
Failing fast causes problems:
Problem 1: Form validation
- User submits form with 10 field errors
- Fail on first error: "Name required"
- User fixes name, submits again
- New error: "Email invalid"
- User submits 10 times before fixing all errors
- Frustration, reduced productivity
Problem 2: Batch processing
- Process 1000 records, fail on record 5
- 995 records not processed
- User manually retries
- Repeats for each error type
- Inefficient
Problem 3: System diagnostics
- Service health check fails
- Report: "Check 1 failed"
- Fix check 1, service still down
- Hidden problem: checks 2, 3, and 4 also failed
- Time wasted diagnosing
Solutions:
Error accumulation:
- Run all validations
- Collect errors
- Report all problems
- User fixes once, not 10 times
Partial success:
- Process all records
- Track successes and failures
- Return: "950 succeeded, 50 failed"
- No re-processing
Comprehensive diagnostics:
- Run all checks
- Report all failures
- Quick root cause analysis
- Faster resolution
🟠 Advanced Patterns
Error Handling Pattern 2: Error Propagation and Chains
Rule: Use error propagation to preserve context through effect chains, enabling debugging and recovery at the right abstraction level.
Good Example:
This example demonstrates error propagation with context.
import { Effect, Data, Cause } from "effect";
class DatabaseError extends Data.TaggedError("DatabaseError")<{
query: string;
parameters: unknown[];
cause: Error;
}> {}
class NetworkError extends Data.TaggedError("NetworkError")<{
endpoint: string;
method: string;
statusCode?: number;
cause: Error;
}> {}
class ValidationError extends Data.TaggedError("ValidationError")<{
field: string;
value: unknown;
reason: string;
}> {}
class BusinessLogicError extends Data.TaggedError("BusinessLogicError")<{
operation: string;
context: Record<string, >;
: ;
}> {}
program = .(* () {
.();
.();
lowLevelOperation = .(* () {
* .();
* .( ());
});
midLevelOperation = lowLevelOperation.(
.(
({
: ,
: [],
: error ? error : ((error)),
})
)
);
highLevelOperation = midLevelOperation.(
.(,
.(* () {
* .();
* .();
* .();
;
})
)
);
result1 = * highLevelOperation;
* .();
.();
{
: ;
: ;
?: ;
: ;
}
= () =>
.(
({
: context.,
: {
: context.,
: context..(),
: context.,
},
: (),
})
);
: = {
: (),
: ,
: ,
: ,
};
withContextRecovery = (myContext).(
.( {
{
...error,
: ,
: {
: ,
: ,
: ,
},
};
}),
.(
.(* () {
* .();
* .();
;
})
)
);
* withContextRecovery;
.();
{
: ;
: ;
: ;
}
attemptCount = ;
networkCall = .(* () {
attemptCount++;
* .();
(attemptCount < ) {
* .(
({
: ,
: ,
: ,
: (),
})
);
}
;
});
withRetryContext = .(* () {
: | = ;
( i = ; i <= ; i++) {
result = * networkCall.(
.(, {
lastError = error;
* .(
);
(i < ) {
* .();
}
.(error);
})
).(
.( .())
).(
.( .())
);
(result !== ) {
result;
}
}
(lastError) {
* .(lastError);
}
;
});
networkResult = * withRetryContext.(
.(
.(* () {
* .();
;
})
)
);
* .();
.();
layer1Error = .(* () {
* .( ());
});
layer2 = layer1Error.(
.(
({
: ,
: [],
: error ? error : ((error)),
})
)
);
layer3 = layer2.(
.(
({
: ,
: {
: dbError.,
},
: dbError.,
})
)
);
userFacingError = layer3.(
.( ({
: ,
: ,
: bizError..,
})),
.(
.(* () {
* .();
* .();
* .();
;
})
)
);
* userFacingError;
.();
= () =>
.(* () {
(shouldFail) {
* .(
()
);
}
;
});
concurrent = .(* () {
results = * .(
[
(, ),
(, ),
(, ),
],
{ : }
).(
.(
.(* () {
* .();
* .();
[];
})
)
);
results;
});
* concurrent;
* .();
});
.(program);
Rationale:
Error propagation preserves context:
- Cause chain: Keep original error + context
- Stack trace: Preserve execution history
- Error context: Add operation name, parameters
- Error mapping: Transform errors between layers
- Recovery points: Decide where to handle errors
Pattern: Use mapError(), tapError(), catchAll(), Cause.prettyPrint()
Loss of error context causes problems:
Problem 1: Useless error messages
- User sees: "Error: null"
- Debugging: Where did it come from? When? Why?
- Wasted hours searching logs
Problem 2: Wrong recovery layer
- Network error → recovered at business logic layer (inefficient)
- Should be recovered at network layer → retry, exponential backoff
Problem 3: Error context loss
- Database connection failed
- But which database? Which query? With what parameters?
- Logs show "Connection failed" (not actionable)
Problem 4: Hidden root cause
- Effect 1 fails → triggers Effect 2 → different error
- Developer sees Effect 2 error
- Doesn't know Effect 1 was root cause
- Fixes wrong thing
Solutions:
Error context:
- Include operation name
- Include relevant parameters
- Include timestamps
- Include retry count
Error cause chains:
- Keep original error
- Add context at each layer
mapError() to transform
tapError() to log context
Recovery layers:
- Low-level: Retry network requests
- Mid-level: Transform domain errors
- High-level: Convert to user-friendly messages
Error Handling Pattern 3: Custom Error Strategies
Rule: Use tagged errors and custom error types to enable type-safe error handling and business-logic-aware recovery strategies.
Good Example:
This example demonstrates custom error strategies.
import { Effect, Data, Schedule } from "effect";
class NetworkError extends Data.TaggedError("NetworkError")<{
endpoint: string;
statusCode?: number;
retryable: boolean;
}> {}
class ValidationError extends Data.TaggedError("ValidationError")<{
field: string;
reason: string;
}> {}
class AuthenticationError extends Data.TaggedError("AuthenticationError")<{
reason: "invalid-token" | "expired-token" | "missing-token";
}> {}
class PermissionError extends Data.TaggedError("PermissionError")<{
resource: string;
action: string;
}> {}
class RateLimitError extends Data.TaggedError("RateLimitError")<{
: ;
}> {}
()<{
: ;
: ;
}> {}
selectRecoveryStrategy = (
:
): | | | => {
(error && error.) {
;
}
(error ) {
;
}
(error ) {
;
}
(error ) {
;
}
(
error &&
error. ===
) {
;
}
(error ) {
;
}
;
};
program = .(* () {
.(
);
.();
operation1 = .(
({
: ,
: ,
})
);
handled1 = operation1.(
.(,
.(* () {
* .();
* .();
* .();
;
})
)
);
* handled1;
.();
{
: ;
?: ;
}
= () =>
.(* () {
(shouldFail) {
:
* .(
({
: ,
: ,
: ,
})
);
:
* .(
({
: ,
: ,
})
);
:
* .(
({
: ,
})
);
:
{ : , : { : } };
}
});
testCases = [, , , ] ;
( testCase testCases) {
strategy = * (testCase).(
.(,
.(* () {
* .(
);
;
})
),
.(,
.(* () {
* .(
);
;
})
),
.(,
.(* () {
* .(
);
;
})
),
.(
.(* () {
* .();
;
})
)
);
* .();
}
.();
attemptCount = ;
networkOperation = .(* () {
attemptCount++;
* .();
(attemptCount === ) {
* .(
({
: ,
: ,
: ,
})
);
}
(attemptCount === ) {
* .(
({
: ,
})
);
}
;
});
: | = ;
( i = ; i < ; i++) {
result3 = * networkOperation.(
.(,
.(* () {
(error. && i < ) {
* .();
;
}
* .();
.(error);
})
),
.(,
.(* () {
* .(
);
* .();
;
})
),
.(
.(* () {
* .();
.(error);
})
)
).(
.( .())
);
(result3 !== ) {
;
}
}
* .();
.();
{
: ;
: ;
}
loadUser = (: ): .<, | > =>
.(* () {
(id === ) {
* .(
({
: ,
id,
})
);
}
(id === ) {
* .(
({
: ,
: ,
})
);
}
{ id, : };
});
= () =>
(id).(
.(,
.(* () {
* .(
);
{ : , : };
})
),
.(,
.(* () {
* .(
);
{ id, : };
})
)
);
* ();
* ();
* ();
.();
classifyError = (
: | | |
): {
(error.) {
:
;
:
;
:
;
:
;
:
: = error;
_exhaustive;
}
};
testError = ({
: ,
: ,
});
classification = (testError);
* .();
.();
resilientOperation = .(* () {
* .(
({
: ,
})
);
});
withRecovery = resilientOperation.(
.(,
.(* () {
* .(
);
* .();
* .();
})
),
.(,
.(* () {
(error.) {
* .();
;
}
* .(error);
})
),
.(
.(* () {
* .();
;
})
)
);
* withRecovery;
});
.(program);
Rationale:
Custom error strategies enable business logic:
- Tagged errors: Effect.Data for type-safe errors
- Error classification: Retryable, transient, permanent
- Domain semantics: Business-meaning errors
- Recovery strategies: Different per error type
- Error context: Includes recovery hints
Pattern: Use Data.TaggedError, error discriminators, catchTag()
Generic errors prevent optimal recovery:
Problem 1: One-size-fits-all retry
- Network timeout (transient, retry with backoff)
- Invalid API key (permanent, don't retry)
- Both treated same = wrong recovery
Problem 2: Lost business intent
- System error: "Connection refused"
- Business meaning: Unclear
- User message: "Something went wrong" (not helpful)
Problem 3: Wrong recovery layer
- Should retry at network layer
- Instead retried at application layer
- Wasted compute, poor user experience
Problem 4: Silent failures
- Multiple error types possible
- Generic catch ignores distinctions
- Bug: handled Error A as if it were Error B
- Data corruption, hard to debug
Solutions:
Tagged errors:
NetworkError, ValidationError, PermissionError
- Type system ensures handling
- TypeScript compiler catches missed cases
- Clear intent
Recovery strategies:
NetworkError → Retry with exponential backoff
ValidationError → Return user message, no retry
PermissionError → Log security event, no retry
TemporaryError → Retry with jitter
Business semantics:
- Error type matches domain concept
- Code reads like domain language
- Easier to maintain
- New developers understand quickly