Effect TS

A course for TypeScript developers

Foundation

4 steps
01
The Mental Model
Why Effect exists & what it replaces
10 min

Before writing any code, understand what Effect actually is. It's NOT just another utility library — it's a complete paradigm for describing programs.

Key InsightEffect<Success, Error, Requirements> — this single type replaces Promise, try/catch, dependency injection, and more. Think of it as a "blueprint" for a computation, not the computation itself.
What to learn
Effect<A, E, R>
The core type. A = success value, E = expected errors, R = required dependencies.
Lazy execution
Unlike Promises, Effects are cold. They describe WHAT to do, not DO it. Nothing runs until you call a runner.
Referential transparency
You can pass Effects around, compose them, store them — they're just values until executed.
Practice
Predict the output

What gets printed to the console? Remember: Effects are lazy blueprints, not eager Promises.

import { Effect } from "effect"

const a = Effect.sync(() => console.log("hello"))
const b = Effect.succeed(42)
console.log("done")

// What prints? (A) "hello" then "done"  (B) "done" only  (C) nothing
Reveal solution
// Answer: (B) "done" only
// Effect.sync wraps a function but does NOT execute it.
// Effect.succeed wraps a value — no side effect at all.
// Only console.log("done") runs because it's plain TS, not an Effect.
Label the types

Fill in the A (success), E (error), and R (requirements) for each Effect type.

import { Effect } from "effect"

// What are A, E, R for each?
const a = Effect.succeed("hello")
// Effect<???, ???, ???>

const b = Effect.fail(new Error("boom"))
// Effect<???, ???, ???>

const c = Effect.sync(() => Math.random())
// Effect<???, ???, ???>
Reveal solution
import { Effect } from "effect"

const a = Effect.succeed("hello")
// Effect<string, never, never>
// A=string (the value), E=never (can't fail), R=never (no deps)

const b = Effect.fail(new Error("boom"))
// Effect<never, Error, never>
// A=never (never succeeds), E=Error, R=never

const c = Effect.sync(() => Math.random())
// Effect<number, never, never>
// A=number (return type of the fn), E=never (sync assumes no throw), R=never
Common TrapComing from async/await, you'll instinctively think an Effect "runs" when created. It doesn't. const x = Effect.log("hi") prints nothing. You must run it.
Read docs →
02
Creating Effects
succeed, fail, sync, promise, try
15 min

Learn the constructors — how to wrap existing values, sync code, and async code into the Effect world.

Key InsightMatch the constructor to what you're wrapping. Already have a value? succeed/fail. Sync code that might throw? try. Async code? promise or tryPromise.
What to learn
Effect.succeed(value)
Wraps a plain value. Always succeeds. Type: Effect<A, never, never>
Effect.fail(error)
Creates a failed Effect. Type: Effect<never, E, never>
Effect.sync(() => ...)
Wraps sync code that WON'T throw. Lazy evaluation.
Effect.try(() => ...)
Wraps sync code that MIGHT throw. Catches and channels the error.
Effect.promise(() => ...)
Wraps a Promise that WON'T reject. For promises that might reject, use tryPromise.
Effect.tryPromise(() => ...)
Wraps a Promise that MIGHT reject. Maps rejection to the error channel.
Example
// Wrapping existing values
const success = Effect.succeed(42)        // Effect<number, never, never>
const failure = Effect.fail("oh no")      // Effect<never, string, never>

// Wrapping sync code
const random = Effect.sync(() => Math.random())
const parsed = Effect.try(() => JSON.parse(input))

// Wrapping async code
const fetched = Effect.tryPromise({
  try: () => fetch("https://api.example.com/data"),
  catch: (err) => new HttpError({ cause: err })
})
Practice
Pick the right constructor

For each scenario, choose the correct Effect constructor. Think about: does it throw? Is it async? Do you already have the value?

import { Effect } from "effect"

// 1. You have a config object already loaded
const config = { port: 3000 }
const getConfig = Effect.???(config)

// 2. Reading from localStorage (sync, might throw in SSR)
const getToken = Effect.???(() => localStorage.getItem("token"))

// 3. Calling an external API (async, might reject)
const fetchUser = Effect.???(() => fetch("/api/user"))

// 4. Getting the current date (sync, never throws)
const now = Effect.???(() => new Date())
Reveal solution
import { Effect } from "effect"

// 1. Already have a value → succeed
const config = { port: 3000 }
const getConfig = Effect.succeed(config)

// 2. Sync code that might throw → try
const getToken = Effect.try(() => localStorage.getItem("token"))

// 3. Async code that might reject → tryPromise
const fetchUser = Effect.tryPromise(() => fetch("/api/user"))

// 4. Sync code that won't throw → sync
const now = Effect.sync(() => new Date())
Wrap a real function

Convert this vanilla TS function into an Effect. JSON.parse can throw, so pick the right constructor and map the error.

import { Effect } from "effect"

// Vanilla TS — throws on invalid JSON
function parseJson(raw: string) {
  return JSON.parse(raw)
}

// TODO: rewrite as an Effect that captures the error
const parseJsonEffect = (raw: string) =>
  Effect.???(/* your code here */)
Reveal solution
import { Effect } from "effect"

// Why is (raw: string) outside Effect.try?
// Effect.try(...) returns a single Effect — a blueprint for one computation.
// The outer function parameterizes it: raw is captured via closure.
// This is the same pattern as: const fn = (x) => new Promise(r => r(x))
// You'll see this everywhere in Effect: regular function outside, Effect inside.

const parseJsonEffect = (raw: string) =>
  Effect.try({
    try: () => JSON.parse(raw),
    catch: (error) => new Error(`Invalid JSON: ${error}`)
  })
// Type: (raw: string) => Effect<unknown, Error, never>
Common TrapDon't use Effect.sync for code that throws — use Effect.try instead. sync assumes no exceptions. Also, never use Effect.promise for fetch() — it can reject, so use tryPromise.
Read docs →
03
Running Effects
runSync, runPromise, runPromiseExit
15 min

Effects are blueprints. Runners execute them. Choose the right runner for your context.

Key InsightThere should be exactly ONE runner at the edge of your program. Everything else composes Effects together without running them.
What to learn
Effect.runSync(effect)
Runs synchronously. Throws if the Effect is async or fails.
Effect.runPromise(effect)
Returns a Promise. Rejects if the Effect fails.
Effect.runPromiseExit(effect)
Returns Promise<Exit> — never rejects, gives you Success or Failure.
Effect.runFork(effect)
Runs on a lightweight Fiber (covered in Phase 8: Concurrency). You won't need this yet.
Example
// One runner at the edge of your program
const program = Effect.gen(function* () {
  yield* Effect.log("Starting...")
  return yield* myApp
})

// Pick the right runner:
Effect.runSync(program)          // sync only, throws on async/failure
Effect.runPromise(program)       // returns Promise, rejects on failure
Effect.runPromiseExit(program)   // returns Promise<Exit>, never rejects
Practice
Match the runner

Each runner behaves differently on success, failure, and async effects. Predict what happens in each case.

import { Effect } from "effect"

const success = Effect.succeed(42)
const failure = Effect.fail("oops")
const async_ = Effect.tryPromise(() => fetch("https://example.com"))

// What does each return or throw?
Effect.runSync(success)     // → ???
Effect.runSync(failure)     // → ???
Effect.runSync(async_)      // → ???

Effect.runPromise(success)  // → ???
Effect.runPromise(failure)  // → ???
Reveal solution
import { Effect } from "effect"

Effect.runSync(success)     // → 42
Effect.runSync(failure)     // → throws (runSync throws when the Effect fails)
Effect.runSync(async_)      // → throws (runSync can't handle async Effects)

Effect.runPromise(success)  // → Promise that resolves to 42
Effect.runPromise(failure)  // → Promise that rejects with "oops"
runPromise vs runSync

This code crashes. Figure out why, and fix it by choosing a different runner.

import { Effect } from "effect"

const fetchData = Effect.tryPromise(
  () => fetch("https://api.example.com/data")
)

// This crashes — why?
const result = Effect.runSync(fetchData)
console.log(result)
Reveal solution
import { Effect } from "effect"

const fetchData = Effect.tryPromise(
  () => fetch("https://api.example.com/data")
)

// runSync can't handle async Effects — fetch returns a Promise.
// Fix: use runPromise, which returns a Promise itself.
Effect.runPromise(fetchData).then(console.log)
Common TrapDon't sprinkle runners throughout your code. One runner at the entry point. Compose everything else with pipe, gen, map, flatMap.
Read docs →
04
Generators (Effect.gen)
The async/await of Effect
15 min

Effect.gen is how you write sequential Effect code that reads like async/await. This is the syntax you'll use 90% of the time.

Key Insightyield* is to Effect.gen what await is to async functions. But unlike await, yield* works with ANY Effect — sync or async, with typed errors and dependencies.
What to learn
Effect.gen(function* () { ... })
Creates an Effect using generator syntax. The most readable way to compose Effects.
yield*
"Unwraps" an Effect inside a generator. Like await but for Effects.
Return value
What you return from the generator becomes the success value of the resulting Effect.
Example
const program = Effect.gen(function* () {
  const user = yield* fetchUser(id)    // like: await fetchUser(id)
  const posts = yield* fetchPosts(user.id)
  return { user, posts }
})
// program is Effect<{ user, posts }, Error, never>
// Nothing has executed yet — it's still just a blueprint!
Practice
Convert async/await to Effect.gen

Rewrite this async function using Effect.gen. Replace await with yield*, and wrap the fetch call properly.

// Original async/await code
async function getUser(id: string) {
  const res = await fetch(`/api/users/${id}`)
  const user = await res.json()
  return user.name
}

// TODO: rewrite using Effect.gen
import { Effect } from "effect"

const getUser = (id: string) =>
  Effect.gen(function* () {
    // your code here
  })
Reveal solution
import { Effect } from "effect"

const getUser = (id: string) =>
  Effect.gen(function* () {
    const res = yield* Effect.tryPromise({
      try: () => fetch(`/api/users/${id}`),
      catch: () => new Error("fetch failed")
    })
    const user = yield* Effect.tryPromise({
      try: () => res.json(),
      catch: () => new Error("invalid JSON")
    })
    return user.name as string
  })
// Type: (id: string) => Effect<string, Error, never>
// The catch mapper controls what goes in the E channel.
// Nothing runs until you call a runner!
Compose multiple effects

Use Effect.gen to sequence three independent effects and combine their results into one object.

import { Effect } from "effect"

const getName = Effect.succeed("Alice")
const getAge = Effect.succeed(30)
const getRole = Effect.succeed("admin")

// TODO: use Effect.gen to combine into { name, age, role }
const getProfile = Effect.gen(function* () {
  // your code here
})

// Then run it:
// Effect.runSync(getProfile)
// → { name: "Alice", age: 30, role: "admin" }
Reveal solution
import { Effect } from "effect"

const getName = Effect.succeed("Alice")
const getAge = Effect.succeed(30)
const getRole = Effect.succeed("admin")

const getProfile = Effect.gen(function* () {
  const name = yield* getName   // no parentheses — getName is already an Effect
  const age = yield* getAge     // same as: await myPromise (not myPromise())
  const role = yield* getRole   // use () only when calling a function that RETURNS an Effect
  return { name, age, role }
})

console.log(Effect.runSync(getProfile))
// → { name: "Alice", age: 30, role: "admin" }
Common TrapYou MUST use yield* (with the asterisk), not plain yield. Also, the generator must be function*, not an arrow function.
Read docs →
Composition