The Effect Type
Read Effect<A, E, R>, create lazy programs, satisfy their requirements, and run them at the application boundary.
an Effect is a value that describes work · running interprets that descriptionRegular TypeScript usually performs work as soon as execution reaches a function call. Effect adds a step in between: first you create a value that describes the work, then a runtime executes that description.
That separation is the foundation of the library. It lets Effect combine programs, track expected failures and dependencies in their types, and control concerns such as retries, concurrency, interruption, and resource safety without hiding them inside every function.
A value that describes work
This ordinary TypeScript expression reads the clock immediately:
const createdAt = Date.now()By contrast, Effect.sync receives a function and stores the work for later:
import { Effect } from "effect"
// Creating this value does not call Date.now().
const createdAt = Effect.sync(() => Date.now())
// Effect<number>createdAt is an ordinary reusable value. You can assign it to a variable, pass it to a function, and compose it with other Effects. None of those operations reads the clock.
The callback runs only when the Effect is executed:
const first = Effect.runSync(createdAt)
const second = Effect.runSync(createdAt)Each evaluation of this Effect calls Date.now() again. An Effect is therefore not a cached result and not a one-shot operation. It is a reusable description of how to produce a result.
How is this different from a Promise?
Calling a typical Promise-returning API starts its work immediately:
// The request starts immediately.
const promise = fetch("/api/users/user-1")
// The request starts only when this Effect is run.
const effect = Effect.tryPromise({
try: (signal) => fetch("/api/users/user-1", { signal }),
catch: (cause) => ({ _tag: "RequestError" as const, cause })
})The Promise does not start the request by itself; calling fetch does, and the returned Promise observes that request. Effects instead describe synchronous or asynchronous programs that the Effect runtime has not started yet. The signal supplied to tryPromise also lets the runtime interrupt the request when the surrounding Effect is interrupted.
Read Effect<A, E, R>
Every Effect answers three independent questions in its type:
What can it produce?
The value returned when the program completes successfully.
UserHow can it fail?
Failures that callers are expected to understand and handle.
UserNotFound | RequestErrorWhat must be supplied?
Services the program needs from its environment before it can run.
ApiClientRead this signature as a sentence:
Effect.Effect<
User, // succeeds with
UserNotFound | RequestError, // can fail with
ApiClient // requires
>This program may produce a
User, may fail withUserNotFoundorRequestError, and needs anApiClientto run.
The type says nothing about the implementation. The program might perform one request, use a cache, retry, or call several systems. Its public contract remains the same.
The success channel
Use Effect.succeed when a successful value already exists:
type User = {
readonly id: string
readonly name: string
}
const ada: User = {
id: "user-1",
name: "Ada"
}
// Effect<User, never, never>
const foundUser = Effect.succeed(ada)The first channel is User. The other two are never: this Effect has no typed failure and no requirement that must be supplied.
Because ada already exists, Effect.succeed does not need a callback. This distinction matters when the expression itself performs work:
// Date.now() runs now, before the Effect exists.
const capturedNow = Effect.succeed(Date.now())
// Date.now() runs later, each time this Effect is evaluated.
const readNow = Effect.sync(() => Date.now())The error channel
Use Effect.fail when an expected failure value already exists:
type UserNotFound = {
readonly _tag: "UserNotFound"
readonly id: string
}
// Effect<never, UserNotFound, never>
const missingUser: Effect.Effect<never, UserNotFound> = Effect.fail({
_tag: "UserNotFound",
id: "user-404"
})The success channel is now never because this particular Effect cannot succeed. Its error channel records UserNotFound, so callers can handle that case without inspecting an unknown thrown value.
The E channel is for failures that are part of the program's expected contract: missing data, rejected input, a declined payment, or an unavailable remote dependency. Defects and interruption are different concepts; the errors article will cover them in detail.
The requirements channel
Some programs cannot run until the application supplies a dependency. A requirement appears in R:
import { Context, Effect } from "effect"
class ApiBaseUrl extends Context.Service<
ApiBaseUrl,
string
>()("app/ApiBaseUrl") {}
// Effect<string, never, ApiBaseUrl>
const apiBaseUrl = Effect.service(ApiBaseUrl)For now, treat ApiBaseUrl as a typed key for a value the program expects to receive. The Services & Layers chapter will explain how to design and construct larger dependencies.
An outstanding requirement is not an error and does not mean the Effect is incomplete. It is an honest part of the description. You can keep composing that Effect, but you must supply the requirement before using a standard runner:
// Type error: ApiBaseUrl is still required.
Effect.runSync(apiBaseUrl)
// Effect<string, never, never>
const runnable = apiBaseUrl.pipe(
Effect.provideService(ApiBaseUrl, "https://api.example.com")
)
Effect.runSync(runnable)
// "https://api.example.com"Effect.provideService removes ApiBaseUrl from R because the value is now available. A and E stay unchanged.
Choose an honest constructor
Constructors differ along two useful questions:
- Does the value already exist, or should its creation be deferred?
- Can creating it fail in a way the caller is expected to handle?
Effect.succeed: a successful value already exists
// Effect<User>
const user = Effect.succeed(ada)The value is evaluated before succeed is called. No callback runs during execution.
Effect.fail: an expected error already exists
// Effect<never, UserNotFound>
const notFound = Effect.fail<UserNotFound>({
_tag: "UserNotFound",
id: "user-404"
})Like succeed, this stores an existing value. The difference is the channel in which that value appears.
Effect.sync: deferred synchronous work that is not expected to fail
// Effect<number>
const now = Effect.sync(() => Date.now())The callback runs each time this Effect is evaluated. Its type has E = never, so use it only when throwing is not part of the expected contract.
Effect.try: deferred synchronous work that may throw
JSON.parse communicates failure by throwing. Effect.try catches that throw and translates it into a typed error:
type ParseError = {
readonly _tag: "ParseError"
readonly cause: unknown
}
const parseJson = (input: string) =>
Effect.try({
try: () => JSON.parse(input) as unknown,
catch: (cause): ParseError => ({ _tag: "ParseError", cause })
})
// Effect<unknown, ParseError>
const parsed = parseJson('{"name":"Ada"}')Compared with Effect.sync: both defer synchronous callbacks. Use sync for work whose expected contract does not include throwing; use try to turn a known throw into E.
Effect.tryPromise: deferred asynchronous work that may reject
Most Promise APIs can reject, so their failure should be translated explicitly:
type RequestError = {
readonly _tag: "RequestError"
readonly cause: unknown
}
const isUser = (value: unknown): value is User =>
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof value.id === "string" &&
"name" in value &&
typeof value.name === "string"
const requestUser = (id: string) =>
Effect.tryPromise({
try: async (signal) => {
const response = await fetch(`/api/users/${id}`, { signal })
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
const body: unknown = await response.json()
if (!isUser(body)) {
throw new Error("Invalid User response")
}
return body
},
catch: (cause): RequestError => ({ _tag: "RequestError", cause })
})
// Effect<User, RequestError>
const requestedUser = requestUser("user-1")Notice the explicit response.ok check. fetch rejects for network failures, but an HTTP 404 or 500 still resolves to a Response. The application must decide how those statuses enter its error model. The small type guard also prevents arbitrary JSON from pretending to be a User; the Schema chapter will replace manual checks like this with composable decoding.
What about Effect.promise?
Effect.promise is for a Promise contract that is genuinely not expected to reject:
// Effect<number>
const answer = Effect.promise(() => Promise.resolve(42))If that Promise rejects, the rejection becomes a defect and does not appear in E. For ordinary calls to fetch, SDKs, or database clients, prefer Effect.tryPromise so rejection is represented honestly.
succeed / fail
Put an existing value into the success or expected-error channel.
sync / try
Use try instead of sync when a known throw belongs in the typed error channel.
promise / tryPromise
Use tryPromise for rejecting Promise APIs. Reserve promise for non-rejecting contracts.
keep it in E
The constructor should reveal failures callers are meant to understand.
Run at the edge
Once an Effect has no outstanding requirements, a runner can execute it.
Effect.runSync
Use runSync only when the entire Effect can finish synchronously:
const program = Effect.sync(() => "ready")
const result = Effect.runSync(program)
// "ready"If execution reaches an asynchronous boundary, runSync cannot wait for it and throws an AsyncFiberError. An already-resolved Promise is still asynchronous.
Effect.runPromise
Use runPromise when the program may perform asynchronous work:
const program = Effect.tryPromise({
try: () => Promise.resolve("ready"),
catch: (cause) => ({ _tag: "UnexpectedError" as const, cause })
})
const result = await Effect.runPromise(program)
// "ready"With the appropriate runner, the complete description is evaluated. These runners are application-boundary tools, not combinators for joining Effects inside business logic.
There is one important type-boundary detail: runPromise returns Promise<A>, not Promise<A | E>. If the Effect still has an unhandled typed error, the Promise rejects; similarly, runSync throws when the Effect fails. TypeScript's Promise and ordinary thrown values no longer preserve the E channel. Handle expected errors while the program is still an Effect, or use an Exit-returning runner when the boundary needs to inspect the complete outcome. The errors chapter will develop both approaches.
// BAD: callers receive a Promise and lose the Effect contract.
const loadUserNow = (id: string) =>
Effect.runPromise(requestUser(id))
// GOOD: callers retain Effect<User, RequestError>.
const loadUser = (id: string) => requestUser(id)
// The application boundary performs the final execution. This Promise rejects
// with RequestError if the expected failure has not already been handled.
const user = await Effect.runPromise(loadUser("user-1"))Understand never
Effect uses never to say that a channel has no possible values:
Cannot succeed
Effect.fail(error) has no successful result.
Effect<never, UserNotFound>No typed failure
The expected-error channel is empty. Defects and interruption can still occur.
Effect<User, never>No requirements left
The program needs no service from Context and can be passed to a standard runner.
Effect<User, E, never>Because E and R default to never, Effect<number> is shorthand for Effect<number, never, never>.
Read types as contracts
When you encounter an unfamiliar Effect, resist reading its implementation first. Start with three questions:
Effect.Effect<User, UserNotFound | RequestError, ApiClient | Logger>
// ^ ^ ^
// A E R- What value is available on success?
- Which failures are callers expected to handle?
- Which capabilities must the application supply?
Later chapters will show how composition changes these channels. For now, the important idea is that an Effect is both an executable description and a precise contract visible to TypeScript.
Knowledge check
Test the model before moving on
Five short tasks reinforce the three type channels, translation between types and contracts, lazy evaluation, and constructor choice.