A Problem Every Seasoned TypeScript Developer Has Faced
Have you written code like this? A chain of nested try/catch blocks, Promise.all inside a try, then a catch wrapping everything — and nobody on the team is sure which errors can occur at which step.
async function processOrder(orderId: string) {
try {
const order = await fetchOrder(orderId); // network error?
try {
const payment = await chargeCard(order); // payment error?
await sendEmail(order, payment); // email error?
} catch (paymentErr) {
// handle separately... but does an email error land here too?
}
} catch (err) {
// err is 'unknown' — no idea what type this is
console.error(err);
}
}
It runs fine in development, but production is a different story. err has type unknown — you can’t tell if it’s a NetworkError, a PaymentDeclinedError, or some generic exception. The TypeScript compiler stays completely silent. Errors only surface when a customer runs into a problem.
I maintained a codebase like this for 6 months before switching to Effect TS — and the difference was stark enough that the team never wanted to go back.
Comparing Error-Handling Approaches in TypeScript
Looking back, there are 4 main approaches — each with its own pain points:
1. Plain try/catch
The only upside: nothing new to learn. The tradeoff — errors have no type, it’s easy to miss a catch where it matters, and when you need multi-step error handling, the code quickly becomes a tangled mess that’s hard to trace.
2. Result/Either type (fp-ts, neverthrow)
The core idea: encode errors into the return type — Result<Value, Error>. The compiler will prompt you to handle errors instead of silently letting them blow up in production. A clear step up from try/catch. But when concurrency comes into play — running 3 requests in parallel and collecting results — the code gets complex fast.
3. Promise + custom error class
A pattern I often see in traditional JS teams is rejecting with a typed error:
class PaymentError extends Error {
constructor(public code: 'declined' | 'invalid_card', message: string) {
super(message);
}
}
// But Promise<T> doesn't encode errors into the type signature
// TypeScript still won't remind you to catch PaymentError
Still “implicit errors” — the compiler doesn’t force you to handle them, and the next person reading the code has no idea what the function might throw.
4. Effect TS
Effect encodes all three dimensions into the type signature: Effect<Success, Error, Requirements>. The compiler knows exactly which errors can occur and forces you to handle them. Concurrency, retry, timeout — all built into the core, no extra libraries needed.
Pros and Cons: Is Effect TS Right for Your Project?
Genuinely Significant Advantages
- Typed errors:
Effect<Order, NetworkError | PaymentError, never>— one glance at the signature tells you exactly what can go wrong, no need to read the implementation - Composability: chain Effects together like LEGO blocks, handle errors at each step or bubble them up in a controlled way
- Concurrency built-in:
Effect.all,Effect.race, fiber-based scheduling — no extra libraries needed - Resource safety:
Effect.acquireReleaseguarantees cleanup runs whether or not an error occurs (same idea asdeferin Go orusingin C#) - Testability: dependency injection via
Requirements— mocking a database in tests is as simple as swapping aLayer, no monkey-patching or complexjest.mocksetups needed
When NOT to Use It
- Small scripts, one-off tools — the overhead of learning Effect isn’t worth it
- Teams unfamiliar with functional programming — the learning curve is steep in the first week
- Projects with tight deadlines — don’t pick up a new paradigm when you’re in a sprint
My usual advice: use Effect for core business logic (payment, order processing), and keep try/catch for small utilities where errors have low impact.
Choosing the Right Approach
A rule of thumb I apply:
- Logic with multiple distinct error types that need different handling → Effect
- Need to run N tasks in parallel with timeout/retry → Effect
- Simple, single-layer CRUD with few edge cases → try/catch or neverthrow is plenty
The most recent web app I worked on had 5 developers. After migrating the core business logic to Effect, PR review time dropped by roughly 40% — reviewers no longer needed to read the implementation to understand what errors a function could throw. After 2 months, production bugs related to unhandled errors dropped to nearly zero.
Practical Implementation with Effect TS
Installation
npm install effect
# or
pnpm add effect
Effect requires no special configuration — just TypeScript 5.0+ with strict: true.
Typed Error Handling: From unknown to Specific
First step — define errors as data:
import { Effect, Data } from 'effect';
// Define errors as tagged data
class NetworkError extends Data.TaggedError('NetworkError')<{
message: string;
statusCode: number;
}> {}
class PaymentDeclinedError extends Data.TaggedError('PaymentDeclinedError')<{
reason: 'insufficient_funds' | 'invalid_card' | 'expired';
}> {}
// The function signature says it all
const fetchOrder = (id: string): Effect.Effect<Order, NetworkError> =>
Effect.tryPromise({
try: () => fetch(`/api/orders/${id}`).then(r => r.json()),
catch: (err) => new NetworkError({
message: String(err),
statusCode: 500
})
});
const chargeCard = (order: Order): Effect.Effect<Payment, NetworkError | PaymentDeclinedError> =>
Effect.tryPromise({
try: () => paymentGateway.charge(order),
catch: (err: any) => {
if (err.code === 'card_declined') {
return new PaymentDeclinedError({ reason: 'insufficient_funds' });
}
return new NetworkError({ message: err.message, statusCode: 500 });
}
});
Chaining and Handling Errors Step by Step
const processOrder = (orderId: string) =>
fetchOrder(orderId).pipe(
Effect.flatMap(order => chargeCard(order)),
// Only handle PaymentDeclinedError here
Effect.catchTag('PaymentDeclinedError', (err) =>
Effect.logWarning(`Payment declined: ${err.reason}`).pipe(
Effect.flatMap(() => Effect.fail(err)) // re-throw after logging
)
),
// NetworkError still propagates to the caller
);
// Run and handle all remaining errors at the top level
const main = processOrder('order-123').pipe(
Effect.catchAll((err) => {
// err here has the exact type: NetworkError | PaymentDeclinedError
switch (err._tag) {
case 'NetworkError':
return Effect.logError(`Network issue: ${err.statusCode}`);
case 'PaymentDeclinedError':
return Effect.logError(`Payment declined: ${err.reason}`);
}
})
);
Effect.runPromise(main);
Concurrency: Controlled Parallel Execution
This is where I find Effect most clearly outperforms Promise.all:
import { Effect } from 'effect';
// Run 3 tasks in parallel, fail immediately if any task fails
const parallel = Effect.all(
[
fetchUserProfile(userId),
fetchUserOrders(userId),
fetchUserSettings(userId),
],
{ concurrency: 'unbounded' } // or limit concurrency: { concurrency: 2 }
);
// Race: take the result from the fastest source
const fromCache = fetchFromCache(key);
const fromDB = fetchFromDatabase(key);
const result = Effect.race(fromCache, fromDB);
// Retry with exponential backoff
const withRetry = fetchOrder(id).pipe(
Effect.retry({
times: 3,
schedule: Schedule.exponential('100 millis')
})
);
// Timeout
const withTimeout = fetchOrder(id).pipe(
Effect.timeout('5 seconds')
);
Resource Management: Guaranteed Cleanup
import { Effect } from 'effect';
// acquireRelease ensures the connection is always closed, even on error
const withDbConnection = Effect.acquireRelease(
Effect.promise(() => pool.connect()), // acquire
(conn) => Effect.promise(() => conn.release()) // release — always runs
);
const queryUser = (id: string) =>
Effect.scoped(
withDbConnection.pipe(
Effect.flatMap(conn =>
Effect.promise(() => conn.query('SELECT * FROM users WHERE id = $1', [id]))
)
)
);
Battle-Tested Tips from Real Projects
- Don’t rewrite the entire codebase at once: Start with your most critical module (payment, auth), then expand gradually. Effect interoperates well with regular Promises via
Effect.promise()andEffect.runPromise(). - Use
Effect.genif your team isn’t comfortable with pipe/flatMap: The syntax is closer to async/await, making onboarding easier:
const processOrder = (orderId: string) =>
Effect.gen(function* () {
const order = yield* fetchOrder(orderId);
const payment = yield* chargeCard(order);
yield* sendConfirmationEmail(order, payment);
return { order, payment };
});
- Layer pattern for dependency injection: Separate interfaces from implementations using
Context.Tag+Layer— inject mocks in tests, real implementations in production. No separate DI framework needed. - Pay attention to Effect’s logging: Effect has structured logging built-in (
Effect.log,Effect.logError). Use it instead ofconsole.log— tracing errors across multiple layers becomes much easier.
Pragmatic Conclusion
Effect TS doesn’t solve every problem — but for serious TypeScript projects where errors need explicit handling and concurrency is non-negotiable, it hits the hardest pain point of traditional try/catch head-on. The type signature becomes living documentation — you know exactly what errors a function can fail with, without reading the implementation or comments.
The first week will be slow. By the third week, you won’t want to go back.
