Introduction to Effect


TL;DR
  • Effect helps you deliver production-grade TypeScript applications
  • An Effect lets you model concurrent programs with type-level error tracking and type-safe dependency injection
  • Provides a cohesive TypeScript standard library and a growing solution ecosystem
  • Effect will force or nudge you to do things well (production-grade), which will often require learning
  • LLMs are very good at Effect, when patterns are clearly established

I have been using Effect for about a year and a half. We chose Effect for a relatively young project because we saw in it the traits that allow writing production-grade, maintainable software.

Since then, we have also used Effect it in green-field project, and would like to build most new services with it.

This is the first in a series where I share Effect in the way I think would make it “click”. It’s not a reference, it’s not a course. It’s an overview that should give you a good idea of its capabilities and what’s available, so that you can use the Effect docs and navigate its API or Effect codebases more effectively (🥁).

This is the first in a series to introduce and learn Effect—sharing what I have learnt.

Note

I use em dashes. I have used em dashes for years. I am not an LLM.

Quick Intro

To me, Effect is primarily a library to write production-grade TypeScript. It aims to cover the needs of a serious production project, facilitating best-practices, covering common needs, filling the gap of an otherwise incoherent ecosystem, not just in terms of code, but in terms of productionazing (e.g. observability). In an Effect engineers can spend their valuable time considering important decisions, including many that often end up forgotten or relegated to tech debt.

On paper that sounds great, but when I show an Effect program, the first reaction is often that the syntax is strange, and the API surface large. And that is true. Initially, Effect can feel foreign to engineers otherwise accustomed to TypeScript. The learning curve is real, but it is a curve that will allow you to write Effect from day 1, and increasingly take advantage of everything else it provides as as you learn.

However, Effect immediately feels more approachable from first principles. And lot of the perceived friction from Effect is in how it forces you to consider important aspects of your application that you may have otherwise ignored. For example, understanding the non-happy path of your programs.

I like to compare Effect to TypeScript:

  • A whole new syntax that feels like a whole new language—yet it is not.
  • When you start, it can feel tedious, even getting in the way of getting your job done, but the trade-off is clear if you realize the extra work is just covering your back, and the significant positive impact it can have on the maintainability and stability of a system in the long run.
  • Like TypeScript, you can start adopting it gradually.
  • Like TypeScript, it will make your code easier to maintain and refactor, especially in teams.
  • TypeScript has become the standard way to write JavaScript, and I hope Effect will become the standard way to write production TypeScript.

Effects

At the core of the Effect library is the Effect type. If you understand what an Effect is, you can make sense of a lot of the library.

You can think of an Effect as a Promise with super powers. Like a Promise, an Effect can model an asynchronous computation, but it makes complex concurrency scenarios simple to describe. Like a Promise, it will end up with a result, but it tracks the possible errors at the type level. And additionally, it tracks dependencies (aka requirements) for the computation at the type level, which must be provided.

At the type level, Effect takes 3 type arguments Effect, although normally you don’t need to provide them because they are inferred.

Effect<Result, Errors, Requirements>

Simple enough, right? Just looking at the types, you can imagine how to represent:

  • An Effect that always succeeds with a number: Effect<number, never, never>
  • An Effect that may fail with ErrorA and ErrorB: Effect<Result, never, never>
  • An Effect that always fails with ErrorA: Effect<never, ErrorA>
Tip

I will cover requirements in more detail in a future post, but for now I want to mention that, by tracking them, Effect enables type-safe dependency injection. These dependencies can be organized and layers, and provided to Effects. This unlocks better code organization and more sane test practices.

Let’s compare how we operate with Promise vs Effect:

PromiseEffect
await somePromiseyield* someEffect
new Promise(function() {})Effect.gen(function* () {})
async function() {}Effect.fn(function* () {})
Promise.resolve('Hi')Effect.succeed('Hi')
Promise.reject('Some error')Effect.fail('Some error')

So yes, it’s different. Yet similar.

However, Effects describe a computation, so the following will not do anything until executed.

const
const myEffect: Effect.Effect<void, never, never>
myEffect
=
import Effect

@since2.0.0

@since2.0.0

@since2.0.0

Effect
.
const gen: <never, void>(f: (resume: Effect.Adapter) => Generator<never, void, never>) => Effect.Effect<void, never, never> (+1 overload)

Provides a way to write effectful code using generator functions, simplifying control flow and error handling.

When to Use

Effect.gen allows you to write code that looks and behaves like synchronous code, but it can handle asynchronous tasks, errors, and complex control flow (like loops and conditions). It helps make asynchronous code more readable and easier to manage.

The generator functions work similarly to async/await but with more explicit control over the execution of effects. You can yield* values from effects and return the final result at the end.

Example

import { Effect } from "effect"
const addServiceCharge = (amount: number) => amount + 1
const applyDiscount = (
total: number,
discountRate: number
): Effect.Effect<number, Error> =>
discountRate === 0
? Effect.fail(new Error("Discount rate cannot be zero"))
: Effect.succeed(total - (total * discountRate) / 100)
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))
const fetchDiscountRate = Effect.promise(() => Promise.resolve(5))
export const program = Effect.gen(function* () {
const transactionAmount = yield* fetchTransactionAmount
const discountRate = yield* fetchDiscountRate
const discountedAmount = yield* applyDiscount(
transactionAmount,
discountRate
)
const finalAmount = addServiceCharge(discountedAmount)
return `Final amount to charge: ${finalAmount}`
})

@since2.0.0

gen
(function* () {
var console: Console

The console module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.

The module exports two specific components:

  • A Console class with methods such as console.log(), console.error() andconsole.warn() that can be used to write to any Node.js stream.
  • A global console instance configured to write to process.stdout and process.stderr. The global console can be used without callingrequire('console').

Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O for more information.

Example using the global console:

console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@seesource

console
.
Console.log(message?: any, ...optionalParams: any[]): void

Prints to stdout with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to printf(3) (the arguments are all passed to util.format()).

const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout

See util.format() for more information.

@sincev0.1.100

log
('Hello Effect!')
})

This is unlike Promises. The following will log to the console, even if you don’t await or do anything with it.

const
const myPromise: Promise<unknown>
myPromise
= new
var Promise: PromiseConstructor
new <unknown>(executor: (resolve: (value: unknown) => void, reject: (reason?: any) => void) => void) => Promise<unknown>

Creates a new Promise.

@paramexecutor A callback used to initialize the promise. This callback is passed two arguments: a resolve callback used to resolve the promise with a value or the result of another promise, and a reject callback used to reject the promise with a provided reason or error.

Promise
(() =>
var console: Console

The console module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.

The module exports two specific components:

  • A Console class with methods such as console.log(), console.error() andconsole.warn() that can be used to write to any Node.js stream.
  • A global console instance configured to write to process.stdout and process.stderr. The global console can be used without callingrequire('console').

Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O for more information.

Example using the global console:

console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@seesource

console
.
Console.log(message?: any, ...optionalParams: any[]): void

Prints to stdout with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to printf(3) (the arguments are all passed to util.format()).

const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout

See util.format() for more information.

@sincev0.1.100

log
('Hello Promise!'))

Effects are executed by a runtime. I’ll cover runtimes in a future post, but for now it’s sufficient to know that you can execute an Effect in a default runtime with Effect.runPromise(effect), which will return a Promise. It’s like turning an Effect into a Promise, really. And you can do the opposite too, with Effect.promise(() => promise).

This shows a Promise calling an Effect that calls a Promise:

import {
import Effect

@since2.0.0

@since2.0.0

@since2.0.0

Effect
} from 'effect';
const
const birthDate: Promise<string>
birthDate
=
var Promise: PromiseConstructor

Represents the completion of an asynchronous operation

Promise
.
PromiseConstructor.resolve<string>(value: string): Promise<string> (+2 overloads)

Creates a new resolved promise for the provided value.

@paramvalue A promise.

@returnsA promise whose internal state matches the provided promise.

resolve
('June 25, 1852')
const
const person: Effect.Effect<{
name: string;
birthDate: string;
}, never, never>
person
=
import Effect

@since2.0.0

@since2.0.0

@since2.0.0

Effect
.
const gen: <YieldWrap<Effect.Effect<string, never, never>>, {
name: string;
birthDate: string;
}>(f: (resume: Effect.Adapter) => Generator<YieldWrap<Effect.Effect<string, never, never>>, {
name: string;
birthDate: string;
}, never>) => Effect.Effect<{
name: string;
birthDate: string;
}, never, never> (+1 overload)

Provides a way to write effectful code using generator functions, simplifying control flow and error handling.

When to Use

Effect.gen allows you to write code that looks and behaves like synchronous code, but it can handle asynchronous tasks, errors, and complex control flow (like loops and conditions). It helps make asynchronous code more readable and easier to manage.

The generator functions work similarly to async/await but with more explicit control over the execution of effects. You can yield* values from effects and return the final result at the end.

Example

import { Effect } from "effect"
const addServiceCharge = (amount: number) => amount + 1
const applyDiscount = (
total: number,
discountRate: number
): Effect.Effect<number, Error> =>
discountRate === 0
? Effect.fail(new Error("Discount rate cannot be zero"))
: Effect.succeed(total - (total * discountRate) / 100)
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))
const fetchDiscountRate = Effect.promise(() => Promise.resolve(5))
export const program = Effect.gen(function* () {
const transactionAmount = yield* fetchTransactionAmount
const discountRate = yield* fetchDiscountRate
const discountedAmount = yield* applyDiscount(
transactionAmount,
discountRate
)
const finalAmount = addServiceCharge(discountedAmount)
return `Final amount to charge: ${finalAmount}`
})

@since2.0.0

gen
(function*() {
return {
name: string
name
: 'Gaudí',
birthDate: string
birthDate
: yield*
import Effect

@since2.0.0

@since2.0.0

@since2.0.0

Effect
.
const promise: <string>(evaluate: (signal: AbortSignal) => PromiseLike<string>) => Effect.Effect<string, never, never>

Creates an Effect that represents an asynchronous computation guaranteed to succeed.

Details

The provided function (thunk) returns a Promise that should never reject; if it does, the error will be treated as a "defect".

This defect is not a standard error but indicates a flaw in the logic that was expected to be error-free. You can think of it similar to an unexpected crash in the program, which can be further managed or logged using tools like

catchAllDefect

.

Interruptions

An optional AbortSignal can be provided to allow for interruption of the wrapped Promise API.

When to Use

Use this function when you are sure the operation will not reject.

Example (Delayed Message)

import { Effect } from "effect"
const delay = (message: string) =>
Effect.promise<string>(
() =>
new Promise((resolve) => {
setTimeout(() => {
resolve(message)
}, 2000)
})
)
// ┌─── Effect<string, never, never>
// ▼
const program = delay("Async operation completed successfully!")

@seetryPromise for a version that can handle failures.

@since2.0.0

promise
(() =>
const birthDate: Promise<string>
birthDate
)
}
})
const
const handleGetCustomerRequest: (customerId: string) => Promise<{
name: string;
birthDate: string;
}>
handleGetCustomerRequest
= async (
customerId: string
customerId
: string) => {
return await
import Effect

@since2.0.0

@since2.0.0

@since2.0.0

Effect
.
const runPromise: <{
name: string;
birthDate: string;
}, never>(effect: Effect.Effect<{
name: string;
birthDate: string;
}, never, never>, options?: {
readonly signal?: AbortSignal | undefined;
} | undefined) => Promise<{
name: string;
birthDate: string;
}>

Executes an effect and returns the result as a Promise.

Details

This function runs an effect and converts its result into a Promise. If the effect succeeds, the Promise will resolve with the successful result. If the effect fails, the Promise will reject with an error, which includes the failure details of the effect.

The optional options parameter allows you to pass an AbortSignal for cancellation, enabling more fine-grained control over asynchronous tasks.

When to Use

Use this function when you need to execute an effect and work with its result in a promise-based system, such as when integrating with third-party libraries that expect Promise results.

Example (Running a Successful Effect as a Promise)

import { Effect } from "effect"
Effect.runPromise(Effect.succeed(1)).then(console.log)
// Output: 1

Example (Handling a Failing Effect as a Rejected Promise)

import { Effect } from "effect"
Effect.runPromise(Effect.fail("my error")).catch(console.error)
// Output:
// (FiberFailure) Error: my error

@seerunPromiseExit for a version that returns an Exit type instead of rejecting.

@since2.0.0

runPromise
(
const person: Effect.Effect<{
name: string;
birthDate: string;
}, never, never>
person
);
}

With those two methods, you have full interop, and the ability to integrate Effect anywhere in an existing program, without having to refactor it all. Eventually you might be tempted to adopt Effect everywhere, but you don’t have to.

The Effect library can acquire and release resources for an Effect, schedule Effects, execute them sequentially or concurrently, etc. This means we can model complex behaviors such as exponential backoff simply. https://effect.kitlangton.com/ does a really good job at explaining concurrency tools in Effect with visualizations.

The Effect ecosystem

Effect includes what could be described as the ideal TypeScript standard library that never was. With it, streams, dates, decimals or arrays gain a predictable API.

@effect/platform is a package that provides means to interact with the runtime (think node, bun or the browser), create HTTP servers, create REST APIs declaratively, and more.

Effect also has official packages to work with databases (@effect/sql), automatic OTEL observability (@effect/opentelemetry), serve and consume RPC (@effect/rpc), create clusters of programs that can talk to each other (@effect/cluster), define durable workflows (@effect/workflow), create CLI commands (@effect/cli), work with AI (@effect/ai), and more.

I plan to cover all of the above in more detail in future posts.

I have found that Effect projects tend to have less dependencies, and that we spend less time managing our dependencies, because there is no glue work nor version conflicts. This is a major pain point of the JavaScript ecosystem that Effect relieves.

On the frontend side, you may want to check out effect-atom. Effect is more useful in the backend currently, but I expect that to change over time.

Effect compounds

If you compare a specific use-case of Effect against the code to do the same without Effect, you might not be convinced. The value is more obvious when you think about maintainability, production needs, and the life-cycle of your program. The example above comparing Effect and Promise might help understand the Effect type, but it doesn’t necessarily sell Effect.

There was a recent interaction on X looking at caching an operation. Doing it without Effect is simple enough. But the Effect approach is:

  • declarative: you say “this Effect should be cached”, vs your inner logic saying “let me cache this”, “let me read from cache now”
  • reusable: the same declarative approach works for any Effect anywhere in your app
  • composable: similarly add error handling, retry policies, batching on top of it—all still declaratively