Effect-TS tracks errors, dependencies, and async operations as first-class types. The core idea: instead of throwing exceptions that callers might miss, every function declares exactly what it can fail with — and TypeScript forces you to handle all of it.
Installation
npm install effect
The Problem
// Standard TypeScript — error cases invisible in the type
async function getUser(id: string): Promise<User> {
const user = await db.user.findUnique({ where: { id } })
// Can throw DatabaseError, user can be null — nothing in the type says so
return user
}
// Effect — errors explicit in the type signature
function getUser(id: string): Effect.Effect<User, DatabaseError | NotFoundError> {
// Callers must handle both error types or TypeScript complains
}
Creating Effects
import { Effect } from 'effect'
// From a value
const succeed = Effect.succeed(42)
// From a throwing function
const fromTryCatch = Effect.try({
try: () => JSON.parse(text),
catch: (e) => new ParseError(`Invalid JSON: ${e}`)
})
// From a Promise
const fromPromise = Effect.tryPromise({
try: () => fetch('/api/data').then(r => r.json()),
catch: (e) => new NetworkError(String(e))
})
Custom Error Types
class DatabaseError {
readonly _tag = 'DatabaseError'
constructor(readonly message: string) {}
}
class NotFoundError {
readonly _tag = 'NotFoundError'
constructor(readonly resource: string, readonly id: string) {}
}
Effect.gen — Readable Async Code
const createPost = Effect.gen(function* () {
const user = yield* findUser(userId) // DatabaseError | NotFoundError propagate up
const slug = slugify(user.name + '-' + title)
const post = yield* Effect.tryPromise({
try: () => db.post.create({ data: { title, slug, authorId: user.id } }),
catch: (e) => new DatabaseError(String(e))
})
return post
})
Reads like async/await but errors accumulate in the type.
Dependency Injection with Layers
import { Effect, Context, Layer } from 'effect'
// Define service interface
interface EmailService {
sendWelcome: (email: string) => Effect.Effect<void, EmailError>
}
const EmailService = Context.GenericTag<EmailService>('EmailService')
// Production implementation
const ResendEmailService = Layer.succeed(EmailService, {
sendWelcome: (email) => Effect.tryPromise({
try: () => resend.emails.send({ to: email, subject: 'Welcome!' }),
catch: (e) => new EmailError(String(e))
})
})
// Mock for tests
const MockEmailService = Layer.succeed(EmailService, {
sendWelcome: (email) => Effect.log(`[Mock] Welcome sent to ${email}`)
})
// Using the service
function registerUser(email: string): Effect.Effect<User, DatabaseError | EmailError, EmailService> {
return Effect.gen(function* () {
const emailSvc = yield* EmailService
const user = yield* createUser(email)
yield* emailSvc.sendWelcome(email)
return user
})
}
// Production
Effect.runPromise(registerUser('alice@example.com').pipe(
Effect.provide(ResendEmailService)
))
// Tests
Effect.runPromise(registerUser('alice@example.com').pipe(
Effect.provide(MockEmailService)
))
Error Handling
const handled = program.pipe(
Effect.catchTag('NotFoundError', (e) =>
Effect.succeed({ found: false, resource: e.resource })
),
Effect.catchTag('DatabaseError', (e) =>
Effect.fail(new ServiceUnavailableError(e.message))
)
)
Concurrency
// Sequential
const [users, posts] = yield* Effect.all([fetchUsers(), fetchPosts()])
// Both run in parallel, both must succeed — if one fails, other is cancelled
// Race
const result = yield* Effect.race(fetchFromPrimary(), fetchFromFallback())
// Timeout
const withTimeout = Effect.timeout(slowOperation(), '5 seconds')
Retry with Schedule
import { Schedule } from 'effect'
const withRetry = fetchData().pipe(
Effect.retry(
Schedule.exponential('100 millis').pipe(
Schedule.jittered,
Schedule.upTo('30 seconds'),
Schedule.compose(Schedule.recurs(3))
)
)
)
Running Effects
// At the edge (HTTP handler, CLI)
const result = await Effect.runPromise(program)
// With error handling at boundary
const safe = await Effect.runPromise(
program.pipe(Effect.catchAll((e) => Effect.succeed(null)))
)
When to Use Effect
Good fit:
- Business logic with many failure modes (payment processing, auth flows)
- Services with external dependencies that need DI without decorators
- Background jobs needing retry, timeout, and circuit breaker patterns
- Large teams where typed errors prevent uncaught exception bugs
Not worth it:
- Simple CRUD APIs
- Small scripts
- Teams not willing to invest in the learning curve
Effect vs fp-ts vs Raw Promises
| Effect | fp-ts | Raw async/await | |
|---|---|---|---|
| Typed errors | ✅ | ✅ (Either) | ❌ |
| DI built-in | ✅ | ❌ | ❌ |
| Concurrency | ✅ Fibers | ❌ | Promise.all |
| Retry/timeout | ✅ Schedule | ❌ | Manual |
| Learning curve | High | Very high | None |
| Readability | Good (gen) | Poor | Excellent |
Effect is effectively fp-ts rewritten with better ergonomics. It's the direction the TypeScript functional community is moving.
Full article at stacknotice.com/blog/effect-ts-complete-guide-2026