If you've spent time building production TypeScript applications, you've probably written some variation of this:
async function getUserData(id: string) {
try {
const user = await db.users.findUnique({ where: { id } });
if (!user) throw new Error('User not found');
const profile = await fetchExternalProfile(user.email);
return { user, profile };
} catch (e) {
// What type is e? Error? unknown? string?
console.error('Something failed', e);
throw e;
}
}
The problem: TypeScript's type system goes completely dark at the boundary of try-catch. You don't know what can throw. You don't know what was thrown. And every caller has to guess.
Effect-TS fixes this at the type level.
What Effect-TS Actually Is
Effect is a library for building programs where:
- Errors are typed and tracked in the return type
- Dependencies are explicit and injectable
- Side effects are controlled and composable
- Concurrency is structured and cancellable
The core type is Effect<A, E, R> where:
-
A= the success value type -
E= the union of possible error types -
R= the required dependencies (context/environment)
Rewriting the Example
import { Effect, Data } from 'effect';
// Define typed error classes
class UserNotFound extends Data.TaggedError('UserNotFound')<{ id: string }> {}
class ProfileFetchError extends Data.TaggedError('ProfileFetchError')<{ email: string; cause: unknown }> {}
// Effect<{ user: User; profile: Profile }, UserNotFound | ProfileFetchError, DatabaseService>
const getUserData = (id: string) =>
Effect.gen(function* () {
const db = yield* DatabaseService;
const user = yield* Effect.tryPromise({
try: () => db.users.findUnique({ where: { id } }),
catch: (cause) => new UserNotFound({ id })
});
if (!user) return yield* Effect.fail(new UserNotFound({ id }));
const profile = yield* Effect.tryPromise({
try: () => fetchExternalProfile(user.email),
catch: (cause) => new ProfileFetchError({ email: user.email, cause })
});
return { user, profile };
});
Now the compiler knows exactly what can fail. No guessing.
Pattern 1: Typed Error Handling
const result = yield* getUserData(userId).pipe(
Effect.catchTag('UserNotFound', (err) =>
Effect.succeed({ user: null, profile: null, notFound: true })
),
Effect.catchTag('ProfileFetchError', (err) => {
console.warn(`Profile unavailable for ${err.email}`);
return Effect.succeed({ user: currentUser, profile: null });
})
);
If you handle all error cases, the error type becomes never. TypeScript enforces exhaustiveness.
Pattern 2: Dependency Injection Without Framework Magic
import { Context, Layer } from 'effect';
// Define the service interface
interface EmailService {
send: (to: string, subject: string, body: string) => Effect.Effect<void, EmailError>
}
const EmailService = Context.GenericTag<EmailService>('EmailService');
// Production implementation
const ResendEmailService = Layer.succeed(EmailService, {
send: (to, subject, body) =>
Effect.tryPromise({
try: () => resend.emails.send({ from: 'atlas@whoffagents.com', to, subject, html: body }),
catch: (cause) => new EmailError({ cause })
})
});
// Test implementation
const MockEmailService = Layer.succeed(EmailService, {
send: (to, subject, body) => Effect.log(`Mock email to ${to}: ${subject}`)
});
// Your program doesn't change
const sendWelcomeEmail = (user: User) =>
Effect.gen(function* () {
const email = yield* EmailService;
yield* email.send(user.email, 'Welcome!', welcomeTemplate(user));
});
// Swap implementations at the edge
Effect.runPromise(sendWelcomeEmail(user).pipe(Effect.provide(ResendEmailService)));
Pattern 3: Structured Concurrency
Effect makes parallel execution safe:
// Run in parallel, fail fast if either fails
const [user, permissions] = yield* Effect.all(
[fetchUser(userId), fetchPermissions(userId)],
{ concurrency: 'unbounded' }
);
// Run with max concurrency
const results = yield* Effect.all(
userIds.map(fetchUser),
{ concurrency: 5 }
);
// Race — take the first success, cancel the rest
const fastestResult = yield* Effect.race(
fetchFromPrimary(id),
fetchFromReplica(id)
);
Pattern 4: Retries and Resilience
import { Schedule } from 'effect';
// Exponential backoff with jitter, max 5 retries
const withRetry = <A, E>(effect: Effect.Effect<A, E>) =>
Effect.retry(
effect,
Schedule.exponential('100 millis').pipe(
Schedule.jittered,
Schedule.upTo('30 seconds'),
Schedule.intersect(Schedule.recurs(5))
)
);
const resilientFetch = withRetry(
Effect.tryPromise({
try: () => fetch('https://api.example.com/data'),
catch: (cause) => new NetworkError({ cause })
})
);
When Effect-TS Is Worth the Learning Curve
Use Effect when:
- Your error surface is wide and callers need to know what can fail
- You need testable dependency injection without a framework
- You're building complex async pipelines with cancellation requirements
- You want structured concurrency without reaching for a third library
Skip Effect when:
- Small scripts or utilities where the overhead isn't justified
- Team has no FP background and you can't invest in onboarding
- Simple CRUD with a single database call per route
Practical Migration Strategy
You don't need to rewrite everything. Effect composes with existing code:
// Wrap your existing async functions
const wrappedExistingFn = (id: string) =>
Effect.tryPromise({
try: () => yourExistingAsyncFunction(id),
catch: (cause) => new ExistingFnError({ cause })
});
// Run Effect programs at your app boundaries
app.get('/users/:id', async (req, res) => {
const result = await Effect.runPromise(
getUserData(req.params.id).pipe(
Effect.provide(ProductionLayers),
Effect.catchAll((err) => Effect.succeed({ error: err._tag }))
)
);
res.json(result);
});
The Real Value Proposition
Effect-TS isn't about making code more functional for its own sake. It's about making the compiler your error documentation. When you change a function that can now throw a new error type, every caller that doesn't handle it becomes a compile error — not a production incident.
For AI agent applications where a single unhandled error can cascade through 10 tool calls, typed error handling pays for itself on the first incident it prevents.
Building AI-native TypeScript applications? The AI SaaS Starter Kit at whoffagents.com ships with typed error handling patterns, structured logging, and a production-ready agent scaffold — so you don't start from scratch.
Top comments (0)