Effect-TS Complete Guide: TypeScript That Handles Errors Properly (2026)

typescript dev.to

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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))
})
Enter fullscreen mode Exit fullscreen mode

Custom Error Types

class DatabaseError {
  readonly _tag = 'DatabaseError'
  constructor(readonly message: string) {}
}

class NotFoundError {
  readonly _tag = 'NotFoundError'
  constructor(readonly resource: string, readonly id: string) {}
}
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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)
))
Enter fullscreen mode Exit fullscreen mode

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))
  )
)
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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))
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

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)))
)
Enter fullscreen mode Exit fullscreen mode

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

Source: dev.to

arrow_back Back to Tutorials