
Effect Ts
- 197 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
effect-ts: A skill for development. This provides functionality for development workflows.
Key points
- effect-ts
Effect Ts by the numbers
- 197 all-time installs (skills.sh)
- +10 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #2,030 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill effect-tsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 197 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use effect-ts for development tasks?
Use effect-ts for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with effect-ts.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use effect-ts for development tasks, or when effect-ts: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to effect-ts: effect-ts.
Files
Gotchas
Effect.gen generators require yield* not yield
Effect generators use yield* (delegating yield) to unwrap effects. Using plain yield produces type errors and incorrect behavior. This is the most common mistake when writing Effect code.
// Wrong
const program = Effect.gen(function* () {
const user = yield getUser(id) // TypeError
})
// Correct
const program = Effect.gen(function* () {
const user = yield* getUser(id)
})Added: 2026-03
Schema.decode vs Schema.decodeUnknown
Schema.decode expects input matching the Encoded type (already partially typed). Schema.decodeUnknownSync / Schema.decodeUnknownEither accept unknown input, which is what you want for parsing external data (API responses, form data, env vars). Added: 2026-03
Layer.provide order matters for composition
When providing multiple layers, dependencies must be provided before the layers that need them. Use Layer.merge for independent layers and Layer.provide to pipe a dependency into a consumer.
// ConfigLive has no dependencies, DbLive depends on Config
const AppLive = DbLive.pipe(Layer.provide(ConfigLive))Added: 2026-03
Effect is lazy — nothing runs until you call runPromise/runSync
Unlike Promises which execute eagerly on construction, Effect values are descriptions of computations. They don't execute until you explicitly run them with Effect.runPromise, Effect.runSync, etc. This is by design but surprises Promise developers. Added: 2026-03
pipe vs .pipe — both work, choose one style
pipe(value, fn1, fn2) (import from "effect") and value.pipe(fn1, fn2) are equivalent. The fluent .pipe style reads top-to-bottom and is generally preferred. Don't mix styles within the same codebase. Added: 2026-03
No further gotchas yet. Append entries as they're discovered during use.
{
"version": "1.0.5",
"organization": "Effect",
"technology": "TypeScript, Effect",
"discipline": "distillation",
"type": "library-reference",
"date": "March 2026",
"abstract": "Comprehensive reference for the Effect TypeScript library, covering the core Effect type, Schema validation, error management, fiber-based concurrency, services and layers, streams, platform APIs, and AI integration.",
"references": [
"https://effect.website/llms.txt",
"https://effect.website/llms-full.txt",
"https://effect.website/docs/getting-started/introduction/",
"https://tim-smart.github.io/effect-io-ai/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group references.
---
1. Getting Started (getting)
Impact: CRITICAL Description: Foundation for all Effect development — includes the paradigm shift guide (mental model, refactoring recipes, anti-patterns, architecture), plus Effect type, pipelines, generators, and execution patterns.
2. Error Management (error)
Impact: CRITICAL Description: Prevents unhandled errors and incorrect recovery — covers typed errors, retrying, timeouts, sandboxing.
3. Schema (schema)
Impact: CRITICAL Description: Prevents invalid data from entering the system — covers schema definitions, transformations, filters, JSON Schema output.
4. Data Types (data)
Impact: HIGH Description: Enables type-safe data modeling — covers Option, Either, Cause, Chunk, DateTime, Duration, Data.
5. Concurrency (conc)
Impact: HIGH Description: Prevents race conditions and deadlocks — covers fibers, Deferred, Latch, PubSub, Queue, Semaphore.
6. Streams and Sinks (streams)
Impact: HIGH Description: Enables efficient streaming data processing — covers stream creation, consumption, operations, sinks.
7. Requirements Management (req)
Impact: HIGH Description: Foundation for Effect dependency injection — covers services, layers, memoization, default services.
8. Resource Management (resource)
Impact: HIGH Description: Prevents resource leaks — covers Scope, safe acquisition and release, caching.
9. State Management (state)
Impact: MEDIUM Description: Enables safe concurrent state — covers Ref, SubscriptionRef, SynchronizedRef.
10. Core Concepts (core)
Impact: MEDIUM Description: Optimizes Effect application architecture — covers request batching, configuration, runtime.
11. Code Style (code)
Impact: MEDIUM Description: Ensures idiomatic Effect code — covers branded types, pattern matching, dual APIs, Equal, Hash.
12. Observability (obs)
Impact: MEDIUM Description: Enables production monitoring and debugging — covers logging, metrics, tracing, Supervisor.
13. Platform (plat)
Impact: MEDIUM Description: Enables cross-platform I/O — covers FileSystem, Command, Terminal, KeyValueStore, Path.
14. Scheduling (sched)
Impact: MEDIUM Description: Enables precise timing control — covers built-in schedules, cron, combinators, repetition.
15. AI Integration (ai)
Impact: LOW Description: Enables LLM tool use with Effect — covers Effect AI packages, execution planning, tool definitions.
16. Testing (test)
Impact: LOW Description: Enables deterministic time-dependent tests — covers TestClock for simulating time passage.
17. Micro (micro)
Impact: LOW Description: Reduces bundle size while preserving Effect patterns — lightweight alternative for smaller apps.
18. Migration (migration)
Impact: LOW Description: Eases adoption from other libraries — covers migration from Promise, fp-ts, neverthrow, ZIO.
Introduction to Effect AI
Overview
Caution: Experimental Module
The Effect AI integration packages are currently in the experimental / alpha stage. We encourage your feedback to further improve their features.
Welcome to the documentation for Effect's AI integration packages — a set of libraries designed to make working with large language models (LLMs) seamless, flexible, and provider-agnostic.
These packages enable you to write programs that describe what you want to do with an LLM — generating completions, handling chat interactions, running function calls — without having to commit to how or where those operations are executed.
The core package, `@effect/ai`, provides a high-level, unified interface for modeling LLM interactions, independent of any specific provider. Once you're ready to run your program, you can plug in the services your program requires from our LLM provider integration packages.
This separation of concerns allows you to:
- Write clean, declarative business logic without worrying about provider-specific quirks
- Easily swap between or combine providers at runtime or during testing
- Take advantage of Effect’s features when building AI-driven workflows
Whether you're building an intelligent agent, an interactive chat app, or a system that leverages LLMs for background tasks, Effect's AI packages offer the flexibility and control you need!
Let’s dive in!
Why Effect for AI?
Integrating LLMs isn’t just about sending API requests — it’s handling streaming output, retries, rate limits, timeouts, and user-driven side effects, all while keeping your system stable and responsive. Effect provides simple, composable building blocks to model these workflows in a safe, declarative, and composable manner.
By using Effect for your LLM interactions you'll benefit from:
- 🧩 Provider-Agnostic Architecture
Write your business logic once, and defer choosing the underlying provider (OpenAI, Anthropic, local models, mocks, etc.) until runtime
- 🧪 Fully Testable
Because LLM interactions are modeled via Effect services, you can mock, simulate, or snapshot responses just by providing an alternative implementation
- 🧵 Structured Concurrency
Run concurrent LLM calls, cancel stale requests, stream partial results, or race multiple providers — all safely managed by Effect’s structured concurrency model
- 🔍 Observability
Leverage Effect's built-in tracing, logging, and metrics to instrument your LLM interactions to gain deep insight into performance bottlenecks or failures in production
...and much more!
Core Concepts
Effect’s AI integrations are built around the idea of provider-agnostic programming. Instead of hardcoding calls to a specific LLM provider's API, you describe your interaction using the services provided by the base @effect/ai package.
These services expose capabilities such as:
- Generating Text – single-shot text generation
- Generating Embeddings – vector representations of text for search or retrieval
- Tool Calling – structured outputs and tool usage
- Streaming – incremental output for memory efficiency and responsiveness
Each of these services is defined as an Effect service — meaning they can be injected, composed, and tested just like any other dependency in the Effect ecosystem.
This decoupling lets you write your AI code as a pure description of what you want to happen, and resolve how it happens later — whether by wiring up OpenAI, Anthropic, a mock service for tests, or even your own custom LLM backend.
---
Packages
Effect’s AI ecosystem is composed of several focused packages:
@effect/ai
Defines the core abstractions for interacting with LLM provider services. This package defines the generic services and helper utilities needed to build AI-powered applications in a provider-agnostic way.
Use this package to:
- Define your application's interaction with an LLM
- Structure chat or completion flows using Effect
- Build type-safe, declarative AI logic
For detailed API documentation, see the API Reference.
@effect/ai-openai
Concrete implementations of services from @effect/ai backed by the OpenAI API.
Supported services include:
LanguageModel(via OpenAI's Chat Completions API)EmbeddingsModel(via OpenAI's Embeddings API)
For detailed API documentation, see the API Reference.
@effect/ai-anthropic
Concrete implementations of services from @effect/ai backed by the Anthropic API.
Supported services include:
LanguageModel(via Anthropic's Messages API)
For detailed API documentation, see the API Reference.
@effect/ai-amazon-bedrock
Concrete implementations of services from @effect/ai backed by Amazon Bedrock.
Supported services include:
LanguageModel(via Amazon Bedrock's Converse API
For detailed API documentation, see the API Reference.
@effect/ai-google
Concrete implementations of services from @effect/ai backed by Google Generative AI.
Supported services include:
LanguageModel(via Google's Gemini API
For detailed API documentation, see the API Reference.
---
Getting Started
Overview
In this getting started guide, we will demonstrate how to generate a simple text completion using an LLM provider (OpenAi) using the Effect AI integration packages.
We’ll walk through:
- Writing provider-agnostic logic to interact with an LLM
- Declaring the specific LLM model to use for the interaction
- Using a provider integration to make the program executable
Installation
First, we will need to install the base @effect/ai package to gain access to the core AI abstractions. In addition, we will need to install at least one provider integration package (in this case @effect/ai-openai):
# Install the base package for the core abstractions (always required)
npm install @effect/ai
# Install one (or more) provider integrations
npm install @effect/ai-openai
# Also add the core Effect package (if not already installed)
npm install effect# Install the base package for the core abstractions (always required)
pnpm add @effect/ai
# Install one (or more) provider integrations
pnpm add @effect/ai-openai
# Also add the core Effect package (if not already installed)
pnpm add effect# Install the base package for the core abstractions (always required)
yarn add @effect/ai
# Install one (or more) provider integrations
yarn add @effect/ai-openai
# Also add the core Effect package (if not already installed)
yarn add effect# Install the base package for the core abstractions (always required)
bun add @effect/ai
# Install one (or more) provider integrations
bun add @effect/ai-openai
# Also add the core Effect package (if not already installed)
bun add effectDefine an Interaction with a Language Model
First let's define a simple interaction with a large language model (LLM):
Example (Using the LanguageModel Service to Generate a Dad Joke)
// Using `LanguageModel` will add it to your program's requirements
//
// ┌─── Effect<GenerateTextResponse<{}>, AiError, LanguageModel>
// ▼
const generateDadJoke = Effect.gen(function*() {
// Use the `LanguageModel` to generate some text
const response = yield* LanguageModel.generateText({
prompt: "Generate a dad joke"
})
// Log the generated text to the console
console.log(response.text)
// Return the response
return response
})Note: Declarative LLM Interactions
Notice that the above code does not know or care which LLM provider (OpenAi, Anthropic, etc.) will be used. Instead, we focus on _what_ we want to accomplish (i.e. our business logic), not _how_ to accomplish it.
Select a Provider
Next, we need to select which model provider we want to use:
Example (Using a Model Provider to Satisfy the LanguageModel Requirement)
const generateDadJoke = Effect.gen(function*() {
const response = yield* LanguageModel.generateText({
prompt: "Generate a dad joke"
})
console.log(response.text)
return response
})
// Create a `Model` which provides a concrete implementation of
// `LanguageModel` and requires an `OpenAiClient`
//
// ┌─── Model<"openai", LanguageModel | ProviderName, OpenAiClient>
// ▼
const Gpt4o = OpenAiLanguageModel.model("gpt-4o")
// Provide the `Model` to the program
//
// ┌─── Effect<GenerateTextResponse<{}>, AiError, OpenAiClient>
// ▼
const main = generateDadJoke.pipe(
Effect.provide(Gpt4o)
)Before moving on, it is important that we understand the purpose of the Model data type.
Understanding Model
The Model data type represents a provider-specific implementation of one or more services, such as LanguageModel or EmbeddingsModel. It is the primary way that you can plug a real large language model into your program.
export interface Model<ProviderName, Provides, Requires> {}An Model has three generic type parameters:
- ProviderName - the name of the large language model provider that will be used
- Provides - the services this model will provide when built
- Requires - the services this model will require to be built
This allows Effect to track which services the Model requires as well as which services the Model will provide.
Creating n Model
To create a Model, you can use the model-specific factory from one of Effect's provider integration packages.
Example (Defining a Model to Interact with OpenAI)
// ┌─── Model<"openai", LanguageModel | ProviderName, OpenAiClient>
// ▼
const Gpt4o = OpenAiLanguageModel.model("gpt-4o")This creates a Model that:
- Provides the
ProviderNameservice, which allows introspection of the current provider in use by the program - Provides an OpenAI-specific implementation of the
LanguageModelservice using"gpt-4o" - Requires an
OpenAiClientto be built
Providing a Model
Once you've created a Model, you can directly Effect.provide it to your Effect programs just like any other service:
// ┌─── Model<"openai", LanguageModel | ProviderName, OpenAiClient>
// ▼
const Gpt4o = OpenAiLanguageModel.model("gpt-4o")
// ┌─── Effect<GenerateTextResponse<{}>, AiError, OpenAiClient>
// ▼
const program = LanguageModel.generateText({
prompt: "Generate a dad joke"
}).pipe(Effect.provide(Gpt4o))Benefits of Model
There are several benefits to this approach:
Reusability
You can provide the same Model to as many programs as you like.
Example (Providing a Model to Multiple Programs)
const generateDadJoke = Effect.gen(function*() {
const response = yield* LanguageModel.generateText({
prompt: "Generate a dad joke"
})
console.log(response.text)
return response
})
const Gpt4o = OpenAiLanguageModel.model("gpt-4o")
const main = Effect.gen(function*() {
// You can provide the `Model` individually to each
// program, or to all of them at once (as we do here)
const res1 = yield* generateDadJoke
const res2 = yield* generateDadJoke
const res3 = yield* generateDadJoke
}).pipe(Effect.provide(Gpt4o))Flexibility
If we know that one model or provider performs better at a given task than another, we can freely mix and match models and providers together.
For example, if we know Anthropic's Claude generates some really great dad jokes, we can mix it into our existing program with just a few lines of code:
Example (Mixing Multiple Providers and Models)
const generateDadJoke = Effect.gen(function*() {
const response = yield* LanguageModel.generateText({
prompt: "Generate a dad joke"
})
console.log(response.text)
return response
})
const Gpt4o = OpenAiLanguageModel.model("gpt-4o")
const Claude37 = AnthropicLanguageModel.model("claude-3-7-sonnet-latest")
// ┌─── Effect<void, AiError, AnthropicClient | OpenAiClient>
// ▼
const main = Effect.gen(function*() {
const res1 = yield* generateDadJoke
const res2 = yield* generateDadJoke
const res3 = yield* Effect.provide(generateDadJoke, Claude37)
}).pipe(Effect.provide(Gpt4o))Because Effect performs type-level dependency tracking, we can see that an AnthropicClient is now required to make our program runnable.
Abstractability
An Model can also be yield*'ed to lift its dependencies into the calling Effect. This is particularly useful when creating services that depend on AI interactions, where you want to avoid leaking service-level dependencies into the service interface.
For example, in the code below the main program is only dependent upon the DadJokes service. All AI requirements are abstracted away into Layer composition.
Example (Abstracting LLM Interactions into a Service)
const Gpt4o = OpenAiLanguageModel.model("gpt-4o")
const Claude37 = AnthropicLanguageModel.model("claude-3-7-sonnet-latest")
class DadJokes extends Effect.Service<DadJokes>()("app/DadJokes", {
effect: Effect.gen(function*() {
// Yielding the model will return a layer with no requirements
//
// ┌─── Layer<LanguageModel | ProviderName>
// ▼
const gpt = yield* Gpt4o
const claude = yield* Claude37
const generateDadJoke = Effect.gen(function*() {
const response = yield* LanguageModel.generateText({
prompt: "Generate a dad joke"
})
console.log(response.text)
return response
})
return {
generateDadJoke: Effect.provide(generateDadJoke, gpt),
generateBetterDadJoke: Effect.provide(generateDadJoke, claude)
}
})
}) {}
// Programs which utilize the `DadJokes` service have no knowledge of
// any AI requirements
//
// ┌─── Effect<void, AiError, DadJokes>
// ▼
const main = Effect.gen(function*() {
const dadJokes = yield* DadJokes
const res1 = yield* dadJokes.generateDadJoke
const res2 = yield* dadJokes.generateBetterDadJoke
})
// The AI requirements are abstracted away into `Layer` composition
//
// ┌─── Layer<DadJokes, never, AnthropicClient | OpenAiClient>
// ▼
DadJokes.DefaultCreate a Provider Client
To make our code executable, we must finish satisfying our program's requirements.
Let's take another look at our program from earlier:
const generateDadJoke = Effect.gen(function*() {
const response = yield* LanguageModel.generateText({
prompt: "Generate a dad joke"
})
console.log(response.text)
return response
})
const Gpt4o = OpenAiLanguageModel.model("gpt-4o")
// ┌─── Effect<GenerateTextResponse<{}>, AiError, OpenAiClient>
// ▼
const main = generateDadJoke.pipe(
Effect.provide(Gpt4o)
)We can see that our main program still requires us to provide an OpenAiClient.
Each of our provider integration packages exports a client module that can be used to construct a client for that provider.
Example (Creating a Client Layer for a Model Provider)
const generateDadJoke = Effect.gen(function*() {
const response = yield* LanguageModel.generateText({
prompt: "Generate a dad joke"
})
console.log(response.text)
return response
})
const Gpt4o = OpenAiLanguageModel.model("gpt-4o")
const main = generateDadJoke.pipe(
Effect.provide(Gpt4o)
)
// Create a `Layer` which produces an `OpenAiClient` and requires
// an `HttpClient`
//
// ┌─── Layer<OpenAiClient, ConfigError, HttpClient>
// ▼
const OpenAi = OpenAiClient.layerConfig({
apiKey: Config.redacted("OPENAI_API_KEY")
})In the code above, we use the layerConfig constructor from the OpenAiClient module to create a Layer which will produce an OpenAiClient. The layerConfig constructor allows us to read in configuration variables using Effect's configuration system.
The provider clients also have a dependency on an HttpClient implementation to avoid any platform dependencies. This way, you can provide whichever HttpClient implementation is most appropriate for the platform your code is running upon.
For example, if we know we are going to run this code in NodeJS, we can utilize the NodeHttpClient module from @effect/platform-node to provide an HttpClient implementation:
const generateDadJoke = Effect.gen(function*() {
const response = yield* LanguageModel.generateText({
prompt: "Generate a dad joke"
})
console.log(response.text)
return response
})
const Gpt4o = OpenAiLanguageModel.model("gpt-4o")
const main = generateDadJoke.pipe(
Effect.provide(Gpt4o)
)
// Create a `Layer` which produces an `OpenAiClient` and requires
// an `HttpClient`
//
// ┌─── Layer<OpenAiClient, ConfigError, HttpClient>
// ▼
const OpenAi = OpenAiClient.layerConfig({
apiKey: Config.redacted("OPENAI_API_KEY")
})
// Provide a platform-specific implementation of `HttpClient` to our
// OpenAi layer
//
// ┌─── Layer<OpenAiClient, ConfigError, never>
// ▼
const OpenAiWithHttp = Layer.provide(OpenAi, NodeHttpClient.layerUndici)Running the Program
Now that we have a Layer which provides us with an OpenAiClient, we're ready to make our main program runnable.
Our final program looks like the following:
const generateDadJoke = Effect.gen(function*() {
const response = yield* LanguageModel.generateText({
prompt: "Generate a dad joke"
})
console.log(response.text)
return response
})
const Gpt4o = OpenAiLanguageModel.model("gpt-4o")
const main = generateDadJoke.pipe(
Effect.provide(Gpt4o)
)
const OpenAi = OpenAiClient.layerConfig({
apiKey: Config.redacted("OPENAI_API_KEY")
})
const OpenAiWithHttp = Layer.provide(OpenAi, NodeHttpClient.layerUndici)
main.pipe(
Effect.provide(OpenAiWithHttp),
Effect.runPromise
)---
Tool Use
Overview
Language models are great at generating text, but often we need them to take real-world actions, such as querying an API, accessing a database, or calling a service. Most LLM providers support this through tool use (also known as function calling), where you expose specific operations in your application that the model can invoke.
Based on the input it receives, a model may choose to invoke (or call) one or more tools to augment its response. Your application then runs the corresponding logic for the tool using the parameters provided by the model. You then return the result to the model, allowing it to include the output in its final response.
The Toolkit simplifies tool integration by offering a structured, type-safe approach to defining tools. It takes care of all the wiring between the model and your application - all you have to do is define the tool and implement its behavior.
Defining a Tool
Let’s walk through a complete example of how to define, implement, and use a tool that fetches a dad joke from the icanhazdadjoke.com API.
1. Define the Tool
We start by defining a tool that the language model will have access to using the Tool.make constructor.
This constructor accepts several parameters that allow us to fully describe the tool to the language model:
description: Provides an optional description of the toolsuccess: The type of value the tool will return if it succeedsfailure: The type of value the tool will return if it failsparameters: The parameters that the tool should be called with
Example (Defining a Tool)
const GetDadJoke = Tool.make("GetDadJoke", {
description: "Get a hilarious dad joke from the ICanHazDadJoke API",
success: Schema.String,
failure: Schema.Never,
parameters: {
searchTerm: Schema.String.annotations({
description: "The search term to use to find dad jokes"
})
}
})Based on the above, a request to call the GetDadJoke tool:
- Takes a single
searchTermparameter - Will return a string if it succeeds (i.e. the joke)
- Does not have any expected failure scenarios
2. Create a Toolkit
Once we have a tool request defined, we can create a Toolkit, which is a collection of tools that the model will have access to.
Example (Creating a Toolkit)
const GetDadJoke = Tool.make("GetDadJoke", {
description: "Get a hilarious dad joke from the ICanHazDadJoke API",
success: Schema.String,
failure: Schema.Never,
parameters: {
searchTerm: Schema.String.annotations({
description: "The search term to use to find dad jokes"
})
}
})
const DadJokeTools = Toolkit.make(GetDadJoke)3. Implement the Logic
The .toLayer(...) method on a Toolkit allows you to define the handlers for each tool in the toolkit. Because .toLayer(...) takes an Effect, we can access services from our application to implement the tool call handlers.
Example (Implementing a Toolkit)
import {
HttpClient,
HttpClientRequest,
HttpClientResponse
} from "@effect/platform"
class DadJoke extends Schema.Class<DadJoke>("DadJoke")({
id: Schema.String,
joke: Schema.String
}) {}
class SearchResponse extends Schema.Class<SearchResponse>("SearchResponse")({
results: Schema.Array(DadJoke)
}) {}
class ICanHazDadJoke extends Effect.Service<ICanHazDadJoke>()("ICanHazDadJoke", {
dependencies: [NodeHttpClient.layerUndici],
effect: Effect.gen(function*() {
const httpClient = yield* HttpClient.HttpClient
const httpClientOk = httpClient.pipe(
HttpClient.filterStatusOk,
HttpClient.mapRequest(HttpClientRequest.prependUrl("https://icanhazdadjoke.com"))
)
const search = Effect.fn("ICanHazDadJoke.search")(
function*(searchTerm: string) {
return yield* httpClientOk.get("/search", {
acceptJson: true,
urlParams: { searchTerm }
}).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)),
Effect.flatMap(({ results }) => Array.head(results)),
Effect.map((joke) => joke.joke),
Effect.orDie
)
}
)
return {
search
} as const
})
}) {}
const GetDadJoke = Tool.make("GetDadJoke", {
description: "Get a hilarious dad joke from the ICanHazDadJoke API",
success: Schema.String,
failure: Schema.Never,
parameters: {
searchTerm: Schema.String.annotations({
description: "The search term to use to find dad jokes"
})
}
})
const DadJokeTools = Toolkit.make(GetDadJoke)
const DadJokeToolHandlers = DadJokeTools.toLayer(
Effect.gen(function*() {
// Access the `ICanHazDadJoke` service
const icanhazdadjoke = yield* ICanHazDadJoke
return {
// Implement the handler for the `GetDadJoke` tool call request
GetDadJoke: ({ searchTerm }) => icanhazdadjoke.search(searchTerm)
}
})
)In the code above:
- We access the
ICanHazDadJokeservice from our application - Register a handler for the
GetDadJoketool using.handle("GetDadJoke", ...) - Use the
.searchmethod on ourICanHazDadJokeservice to search for a dad joke based on the tool call parameters
The result of calling .toLayer on a Toolkit is a Layer that contains the handlers for all the tools in our toolkit.
Because of this, it is quite simple to test a Toolkit by using .toLayer to create a separate Layer specifically for testing.
4. Give the Tools to the Model
Once the tools are defined and implemented, you can pass them along to the model at request time. Behind the scenes, the model is given a structured description of each tool and can choose to call one or more of them when responding to input.
Example (Using a Toolkit)
const GetDadJoke = Tool.make("GetDadJoke", {
description: "Get a hilarious dad joke from the ICanHazDadJoke API",
success: Schema.String,
failure: Schema.Never,
parameters: {
searchTerm: Schema.String.annotations({
description: "The search term to use to find dad jokes"
})
}
})
const DadJokeTools = Toolkit.make(GetDadJoke)
const generateDadJoke = LanguageModel.generateText({
prompt: "Generate a dad joke about pirates",
toolkit: DadJokeTools
})5. Bring It All Together
To make the program executable, we must provide the implementation of our tool call handlers:
Example (Providing the Tool Call Handlers to a Program)
import {
HttpClient,
HttpClientRequest,
HttpClientResponse
} from "@effect/platform"
class DadJoke extends Schema.Class<DadJoke>("DadJoke")({
id: Schema.String,
joke: Schema.String
}) {}
class SearchResponse extends Schema.Class<SearchResponse>("SearchResponse")({
results: Schema.Array(DadJoke)
}) {}
class ICanHazDadJoke extends Effect.Service<ICanHazDadJoke>()("ICanHazDadJoke", {
dependencies: [NodeHttpClient.layerUndici],
effect: Effect.gen(function*() {
const httpClient = yield* HttpClient.HttpClient
const httpClientOk = httpClient.pipe(
HttpClient.filterStatusOk,
HttpClient.mapRequest(HttpClientRequest.prependUrl("https://icanhazdadjoke.com"))
)
const search = Effect.fn("ICanHazDadJoke.search")(
function*(searchTerm: string) {
return yield* httpClientOk.get("/search", {
acceptJson: true,
urlParams: { searchTerm }
}).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)),
Effect.flatMap(({ results }) => Array.head(results)),
Effect.map((joke) => joke.joke),
Effect.scoped,
Effect.orDie
)
}
)
return {
search
} as const
})
}) {}
const GetDadJoke = Tool.make("GetDadJoke", {
description: "Get a hilarious dad joke from the ICanHazDadJoke API",
success: Schema.String,
failure: Schema.Never,
parameters: {
searchTerm: Schema.String.annotations({
description: "The search term to use to find dad jokes"
})
}
})
const DadJokeTools = Toolkit.make(GetDadJoke)
const DadJokeToolHandlers = DadJokeTools.toLayer(
Effect.gen(function*() {
const icanhazdadjoke = yield* ICanHazDadJoke
return {
GetDadJoke: ({ searchTerm }) => icanhazdadjoke.search(searchTerm)
}
})
).pipe(Layer.provide(ICanHazDadJoke.Default))
const program = LanguageModel.generateText({
prompt: "Generate a dad joke about pirates",
toolkit: DadJokeTools
}).pipe(
Effect.flatMap((response) => Console.log(response.text)),
Effect.provide(OpenAiLanguageModel.model("gpt-4o"))
)
const OpenAi = OpenAiClient.layerConfig({
apiKey: Config.redacted("OPENAI_API_KEY")
}).pipe(Layer.provide(NodeHttpClient.layerUndici))
program.pipe(
Effect.provide([OpenAi, DadJokeToolHandlers]),
Effect.runPromise
)Benefits
Type Safe
Every tool is fully described using Effect's Schema, including inputs, outputs, and descriptions.
Effect Native
Tool call behavior is defined using Effect, so they can leverage all the power of Effect. This is especially useful when you need to access other services to support the implementation of your tool call handlers.
Injectable
Because implementing the handlers for an Toolkit results in a Layer, providing alternate implementation of tool call handlers in different environments is as simple as providing a different Layer to your program.
Separation of Concerns
The definition of a tool call request is cleanly separated from both the implementation of the tool behavior, as well as the business logic that calls the model.
---
Execution Planning
Overview
Imagine that we've refactored our generateDadJoke program from our Getting Started guide. Now, instead of handling all errors internally, the code can fail with domain-specific issues like network interruptions or provider outages:
import type { LanguageModel } from "@effect/ai"
class NetworkError extends Data.TaggedError("NetworkError") {}
class ProviderOutage extends Data.TaggedError("ProviderOutage") {}
declare const generateDadJoke: Effect.Effect<
LanguageModel.GenerateTextResponse<{}>,
NetworkError | ProviderOutage,
LanguageModel.LanguageModel
>
const main = Effect.gen(function*() {
const response = yield* generateDadJoke
console.log(response.text)
}).pipe(Effect.provide(OpenAiLanguageModel.model("gpt-4o")))This is fine, but what if we want to:
- Retry the program a fixed number of times on
NetworkErrors - Add some backoff delay between retries
- Fallback to a different model provider if OpenAi is down
How can we accomplish such logic?
Planning LLM Interactions
The ExecutionPlan module from Effect provides a robust method for creating structured execution plans for your Effect programs. Rather than making a single model call and hoping that it succeeds, you can use ExecutionPlan to describe how to handle errors, retries, and fallbacks in a clear, declarative way.
This is especially useful when:
- You want to fall back to a secondary model if the primary one is unavailable
- You want to retry on transient errors (e.g. network failures)
- You want to control timing between retry attempts
Creating Execution Plans
To create an ExecutionPlan, we can use the ExecutionPlan.make constructor.
Example (Creating an ExecutionPlan for LLM Interactions)
import type { LanguageModel } from "@effect/ai"
class NetworkError extends Data.TaggedError("NetworkError") {}
class ProviderOutage extends Data.TaggedError("ProviderOutage") {}
declare const generateDadJoke: Effect.Effect<
LanguageModel.GenerateTextResponse<{}>,
NetworkError | ProviderOutage,
LanguageModel.LanguageModel
>
const DadJokePlan = ExecutionPlan.make({
provide: OpenAiLanguageModel.model("gpt-4o"),
attempts: 3,
schedule: Schedule.exponential("100 millis", 1.5),
while: (error: NetworkError | ProviderOutage) =>
error._tag === "NetworkError"
})
// ┌─── Effect<void, NetworkError | ProviderOutage, OpenAiClient>
// ▼
const main = Effect.gen(function*() {
const response = yield* generateDadJoke
console.log(response.text)
}).pipe(Effect.withExecutionPlan(DadJokePlan))This plan contains a single step which will:
- Provide OpenAi's
"gpt-4o"model as aLanguageModelfor the program - Attempt to call OpenAi up to 3 times
- Wait with an exponential backoff between attempts (starting at
100ms) - Only re-attempt the call to OpenAi if the error is a
NetworkError
Adding Fallback Models
To make your interactions with large language models resilient to provider outages, you can define a fallback models to use. This will allow the plan to automatically fallback to another model if the previous step in the execution plan fails.
Use this when:
- You want to make your model interactions resilient to provider outages
- You want to potentially have multiple fallback models
Example (Adding a Fallback to Anthropic from OpenAi)
import type { LanguageModel } from "@effect/ai"
class NetworkError extends Data.TaggedError("NetworkError") {}
class ProviderOutage extends Data.TaggedError("ProviderOutage") {}
declare const generateDadJoke: Effect.Effect<
LanguageModel.GenerateTextResponse<{}>,
NetworkError | ProviderOutage,
LanguageModel.LanguageModel
>
const DadJokePlan = ExecutionPlan.make({
provide: OpenAiLanguageModel.model("gpt-4o"),
attempts: 3,
schedule: Schedule.exponential("100 millis", 1.5),
while: (error: NetworkError | ProviderOutage) =>
error._tag === "NetworkError"
}, {
provide: AnthropicLanguageModel.model("claude-4-sonnet-20250514"),
attempts: 2,
schedule: Schedule.exponential("100 millis", 1.5),
while: (error: NetworkError | ProviderOutage) =>
error._tag === "ProviderOutage"
})
// ┌─── Effect<..., ..., AnthropicClient | OpenAiClient>
// ▼
const main = Effect.gen(function*() {
const response = yield* generateDadJoke
console.log(response.text)
}).pipe(Effect.withExecutionPlan(DadJokePlan))This plan contains two steps.
Step 1
The first step will:
- Provide OpenAi's
"gpt-4o"model as aLanguageModelfor the program - Attempt to call OpenAi up to 3 times
- Wait with an exponential backoff between attempts (starting at
100ms) - Only attempt the call to OpenAi if the error is a
NetworkError
If all of the above logic fails to run the program successfully, the plan will try to run the program using the second step.
Step 2
The second step will:
- Provide Anthropic's
"claude-4-sonnet-20250514"model as aLanguageModelfor the program - Attempt to call Anthropic up to 2 times
- Wait with an exponential backoff between attempts (starting at
100ms) - Only attempt the fallback if the error is a
ProviderOutage
End-to-End Usage
The following is the complete program with the desired execution plan fully implemented:
import type { LanguageModel } from "@effect/ai"
class NetworkError extends Data.TaggedError("NetworkError") {}
class ProviderOutage extends Data.TaggedError("ProviderOutage") {}
declare const generateDadJoke: Effect.Effect<
LanguageModel.GenerateTextResponse<{}>,
NetworkError | ProviderOutage,
LanguageModel.LanguageModel
>
const DadJokePlan = ExecutionPlan.make({
provide: OpenAiLanguageModel.model("gpt-4o"),
attempts: 3,
schedule: Schedule.exponential("100 millis", 1.5),
while: (error: NetworkError | ProviderOutage) =>
error._tag === "NetworkError"
}, {
provide: AnthropicLanguageModel.model("claude-4-sonnet-20250514"),
attempts: 2,
schedule: Schedule.exponential("100 millis", 1.5),
while: (error: NetworkError | ProviderOutage) =>
error._tag === "ProviderOutage"
})
const main = Effect.gen(function*() {
const response = yield* generateDadJoke
console.log(response.text)
}).pipe(Effect.withExecutionPlan(DadJokePlan))
const Anthropic = AnthropicClient.layerConfig({
apiKey: Config.redacted("ANTHROPIC_API_KEY")
}).pipe(Layer.provide(NodeHttpClient.layerUndici))
const OpenAi = OpenAiClient.layerConfig({
apiKey: Config.redacted("OPENAI_API_KEY")
}).pipe(Layer.provide(NodeHttpClient.layerUndici))
main.pipe(
Effect.provide([Anthropic, OpenAi]),
Effect.runPromise
)---
Common Mistakes
Incorrect (unstructured LLM tool definitions):
const tools = [{
name: "search",
description: "Search the web",
parameters: { query: "string" } // No validation
}]Correct (using AiToolkit for typed tool definitions):
import { AiToolkit } from "@effect/ai"
import { Schema } from "effect"
const tools = AiToolkit.empty.pipe(
AiToolkit.addTool("search", {
description: "Search the web",
parameters: Schema.Struct({ query: Schema.String }),
handler: ({ query }) => Effect.succeed(`Results for: ${query}`)
})
)Branded Types
Overview
In this guide, we will explore the concept of branded types in TypeScript and learn how to create and work with them using the Brand module. Branded types are TypeScript types with an added type tag that helps prevent accidental usage of a value in the wrong context. They allow us to create distinct types based on an existing underlying type, enabling type safety and better code organization.
The Problem with TypeScript's Structural Typing
TypeScript's type system is structurally typed, meaning that two types are considered compatible if their members are compatible. This can lead to situations where values of the same underlying type are used interchangeably, even when they represent different concepts or have different meanings.
Consider the following types:
type UserId = number
type ProductId = numberHere, UserId and ProductId are structurally identical as they are both based on number. TypeScript will treat these as interchangeable, potentially causing bugs if they are mixed up in your application.
Example (Unintended Type Compatibility)
type UserId = number
type ProductId = number
const getUserById = (id: UserId) => {
// Logic to retrieve user
}
const getProductById = (id: ProductId) => {
// Logic to retrieve product
}
const id: UserId = 1
getProductById(id) // No type error, but incorrect usageIn the example above, passing a UserId to getProductById does not produce a type error, even though it's logically incorrect. This happens because both types are considered interchangeable.
How Branded Types Help
Branded types allow you to create distinct types from the same underlying type by adding a unique type tag, enforcing proper usage at compile-time.
Branding is accomplished by adding a symbolic identifier that distinguishes one type from another at the type level. This method ensures that types remain distinct without altering their runtime characteristics.
Let's start by introducing the BrandTypeId symbol:
const BrandTypeId: unique symbol = Symbol.for("effect/Brand")
type ProductId = number & {
readonly [BrandTypeId]: {
readonly ProductId: "ProductId" // unique identifier for ProductId
}
}This approach assigns a unique identifier as a brand to the number type, effectively differentiating ProductId from other numerical types. The use of a symbol ensures that the branding field does not conflict with any existing properties of the number type.
Attempting to use a UserId in place of a ProductId now results in an error:
Example (Enforcing Type Safety with Branded Types)
const BrandTypeId: unique symbol = Symbol.for("effect/Brand")
type ProductId = number & {
readonly [BrandTypeId]: {
readonly ProductId: "ProductId"
}
}
const getProductById = (id: ProductId) => {
// Logic to retrieve product
}
type UserId = number
const id: UserId = 1
// @errors: 2345
getProductById(id)The error message clearly states that a number cannot be used in place of a ProductId.
TypeScript won't let us pass an instance of number to the function accepting ProductId because it's missing the brand field.
Let's add branding to UserId as well:
Example (Branding UserId and ProductId)
const BrandTypeId: unique symbol = Symbol.for("effect/Brand")
type ProductId = number & {
readonly [BrandTypeId]: {
readonly ProductId: "ProductId" // unique identifier for ProductId
}
}
const getProductById = (id: ProductId) => {
// Logic to retrieve product
}
type UserId = number & {
readonly [BrandTypeId]: {
readonly UserId: "UserId" // unique identifier for UserId
}
}
declare const id: UserId
// @errors: 2345
getProductById(id)The error indicates that while both types use branding, the unique values associated with the branding fields ("ProductId" and "UserId") ensure they remain distinct and non-interchangeable.
Generalizing Branded Types
To enhance the versatility and reusability of branded types, they can be generalized using a standardized approach:
const BrandTypeId: unique symbol = Symbol.for("effect/Brand")
// Create a generic Brand interface using a unique identifier
interface Brand<in out ID extends string | symbol> {
readonly [BrandTypeId]: {
readonly [id in ID]: ID
}
}
// Define a ProductId type branded with a unique identifier
type ProductId = number & Brand<"ProductId">
// Define a UserId type branded similarly
type UserId = number & Brand<"UserId">This design allows any type to be branded using a unique identifier, either a string or symbol.
Here's how you can utilize the Brand interface, which is readily available from the Brand module, eliminating the need to craft your own implementation:
Example (Using the Brand Interface from the Brand Module)
// Define a ProductId type branded with a unique identifier
type ProductId = number & Brand.Brand<"ProductId">
// Define a UserId type branded similarly
type UserId = number & Brand.Brand<"UserId">However, creating instances of these types directly leads to an error because the type system expects the brand structure:
Example (Direct Assignment Error)
const BrandTypeId: unique symbol = Symbol.for("effect/Brand")
interface Brand<in out K extends string | symbol> {
readonly [BrandTypeId]: {
readonly [k in K]: K
}
}
type ProductId = number & Brand<"ProductId">
// @errors: 2322
const id: ProductId = 1You cannot directly assign a number to ProductId. The Brand module provides utilities to correctly construct values of branded types.
Constructing Branded Types
The Brand module provides two main functions for creating branded types: nominal and refined.
nominal
The Brand.nominal function is designed for defining branded types that do not require runtime validations. It simply adds a type tag to the underlying type, allowing us to distinguish between values of the same type but with different meanings. Nominal branded types are useful when we only want to create distinct types for clarity and code organization purposes.
Example (Defining Distinct Identifiers with Nominal Branding)
// Define UserId as a branded number
type UserId = number & Brand.Brand<"UserId">
// Constructor for UserId
const UserId = Brand.nominal<UserId>()
const getUserById = (id: UserId) => {
// Logic to retrieve user
}
// Define ProductId as a branded number
type ProductId = number & Brand.Brand<"ProductId">
// Constructor for ProductId
const ProductId = Brand.nominal<ProductId>()
const getProductById = (id: ProductId) => {
// Logic to retrieve product
}Attempting to assign a non-ProductId value will result in a compile-time error:
Example (Type Safety with Branded Identifiers)
type UserId = number & Brand.Brand<"UserId">
const UserId = Brand.nominal<UserId>()
const getUserById = (id: UserId) => {
// Logic to retrieve user
}
type ProductId = number & Brand.Brand<"ProductId">
const ProductId = Brand.nominal<ProductId>()
const getProductById = (id: ProductId) => {
// Logic to retrieve product
}
// Correct usage
getProductById(ProductId(1))
// Incorrect, will result in an error
// @errors: 2345
getProductById(1)
// Also incorrect, will result in an error
// @errors: 2345
getProductById(UserId(1))refined
The Brand.refined function enables the creation of branded types that include data validation. It requires a refinement predicate to check the validity of input data against specific criteria.
When the input data does not meet the criteria, the function uses Brand.error to generate a BrandErrors data type. This provides detailed information about why the validation failed.
Example (Creating a Branded Type with Validation)
// Define a branded type 'Int' to represent integer values
type Int = number & Brand.Brand<"Int">
// Define the constructor using 'refined' to enforce integer values
const Int = Brand.refined<Int>(
// Validation to ensure the value is an integer
(n) => Number.isInteger(n),
// Provide an error if validation fails
(n) => Brand.error(`Expected ${n} to be an integer`)
)Example (Using the Int Constructor)
type Int = number & Brand.Brand<"Int">
const Int = Brand.refined<Int>(
// Check if the value is an integer
(n) => Number.isInteger(n),
// Error message if the value is not an integer
(n) => Brand.error(`Expected ${n} to be an integer`)
)
// Create a valid Int value
const x: Int = Int(3)
console.log(x) // Output: 3
// Attempt to create an Int with an invalid value
const y: Int = Int(3.14)
// throws [ { message: 'Expected 3.14 to be an integer' } ]Attempting to assign a non-Int value will result in a compile-time error:
Example (Compile-Time Error for Incorrect Assignments)
type Int = number & Brand.Brand<"Int">
const Int = Brand.refined<Int>(
(n) => Number.isInteger(n),
(n) => Brand.error(`Expected ${n} to be an integer`)
)
// Correct usage
const good: Int = Int(3)
// Incorrect, will result in an error
// @errors: 2322
const bad1: Int = 3
// Also incorrect, will result in an error
// @errors: 2322
const bad2: Int = 3.14Combining Branded Types
In some cases, you might need to combine multiple branded types. The Brand module provides the Brand.all API for this purpose:
Example (Combining Multiple Branded Types)
type Int = number & Brand.Brand<"Int">
const Int = Brand.refined<Int>(
(n) => Number.isInteger(n),
(n) => Brand.error(`Expected ${n} to be an integer`)
)
type Positive = number & Brand.Brand<"Positive">
const Positive = Brand.refined<Positive>(
(n) => n > 0,
(n) => Brand.error(`Expected ${n} to be positive`)
)
// Combine the Int and Positive constructors
// into a new branded constructor PositiveInt
const PositiveInt = Brand.all(Int, Positive)
// Extract the branded type from the PositiveInt constructor
type PositiveInt = Brand.Brand.FromConstructor<typeof PositiveInt>
// Usage example
// Valid positive integer
const good: PositiveInt = PositiveInt(10)
// throws [ { message: 'Expected -5 to be positive' } ]
const bad1: PositiveInt = PositiveInt(-5)
// throws [ { message: 'Expected 3.14 to be an integer' } ]
const bad2: PositiveInt = PositiveInt(3.14)Simplifying Excessive Nesting
Overview
Suppose you want to create a custom function elapsed that prints the elapsed time taken by an effect to execute.
Using plain pipe
Initially, you may come up with code that uses the standard pipe method, but this approach can lead to excessive nesting and result in verbose and hard-to-read code:
Example (Measuring Elapsed Time with pipe)
// Get the current timestamp
const now = Effect.sync(() => new Date().getTime())
// Prints the elapsed time occurred to `self` to execute
const elapsed = <R, E, A>(
self: Effect.Effect<A, E, R>
): Effect.Effect<A, E, R> =>
now.pipe(
Effect.andThen((startMillis) =>
self.pipe(
Effect.andThen((result) =>
now.pipe(
Effect.andThen((endMillis) => {
// Calculate the elapsed time in milliseconds
const elapsed = endMillis - startMillis
// Log the elapsed time
return Console.log(`Elapsed: ${elapsed}`).pipe(
Effect.map(() => result)
)
})
)
)
)
)
)
// Simulates a successful computation with a delay of 200 milliseconds
const task = Effect.succeed("some task").pipe(Effect.delay("200 millis"))
const program = elapsed(task)
Effect.runPromise(program).then(console.log)
/*
Output:
Elapsed: 204
some task
*/To address this issue and make the code more manageable, there is a solution: the "do simulation."
Using the "do simulation"
The "do simulation" in Effect allows you to write code in a more declarative style, similar to the "do notation" in other programming languages. It provides a way to define variables and perform operations on them using functions like Effect.bind and Effect.let.
Here's how the do simulation works:
1. Start the do simulation using the Effect.Do value:
const program = Effect.Do.pipe(/* ... rest of the code */)2. Within the do simulation scope, you can use the Effect.bind function to define variables and bind them to Effect values:
Effect.bind("variableName", (scope) => effectValue)variableNameis the name you choose for the variable you want to define. It must be unique within the scope.effectValueis theEffectvalue that you want to bind to the variable. It can be the result of a function call or any other validEffectvalue.
3. You can accumulate multiple Effect.bind statements to define multiple variables within the scope:
Effect.bind("variable1", () => effectValue1),
Effect.bind("variable2", ({ variable1 }) => effectValue2),
// ... additional bind statements4. Inside the do simulation scope, you can also use the Effect.let function to define variables and bind them to simple values:
Effect.let("variableName", (scope) => simpleValue)variableNameis the name you give to the variable. Like before, it must be unique within the scope.simpleValueis the value you want to assign to the variable. It can be a simple value like anumber,string, orboolean.
5. Regular Effect functions like Effect.andThen, Effect.flatMap, Effect.tap, and Effect.map can still be used within the do simulation. These functions will receive the accumulated variables as arguments within the scope:
Effect.andThen(({ variable1, variable2 }) => {
// Perform operations using variable1 and variable2
// Return an `Effect` value as the result
})With the do simulation, you can rewrite the elapsed function like this:
Example (Using Do Simulation to Measure Elapsed Time)
// Get the current timestamp
const now = Effect.sync(() => new Date().getTime())
const elapsed = <R, E, A>(
self: Effect.Effect<A, E, R>
): Effect.Effect<A, E, R> =>
Effect.Do.pipe(
Effect.bind("startMillis", () => now),
Effect.bind("result", () => self),
Effect.bind("endMillis", () => now),
Effect.let(
"elapsed",
// Calculate the elapsed time in milliseconds
({ startMillis, endMillis }) => endMillis - startMillis
),
// Log the elapsed time
Effect.tap(({ elapsed }) => Console.log(`Elapsed: ${elapsed}`)),
Effect.map(({ result }) => result)
)
// Simulates a successful computation with a delay of 200 milliseconds
const task = Effect.succeed("some task").pipe(Effect.delay("200 millis"))
const program = elapsed(task)
Effect.runPromise(program).then(console.log)
/*
Output:
Elapsed: 204
some task
*/Using Effect.gen
The most concise and convenient solution is to use Effect.gen, which allows you to work with generators when dealing with effects. This approach leverages the native scope provided by the generator syntax, avoiding excessive nesting and leading to more concise code.
Example (Using Effect.gen to Measure Elapsed Time)
// Get the current timestamp
const now = Effect.sync(() => new Date().getTime())
// Prints the elapsed time occurred to `self` to execute
const elapsed = <R, E, A>(
self: Effect.Effect<A, E, R>
): Effect.Effect<A, E, R> =>
Effect.gen(function* () {
const startMillis = yield* now
const result = yield* self
const endMillis = yield* now
// Calculate the elapsed time in milliseconds
const elapsed = endMillis - startMillis
// Log the elapsed time
console.log(`Elapsed: ${elapsed}`)
return result
})
// Simulates a successful computation with a delay of 200 milliseconds
const task = Effect.succeed("some task").pipe(Effect.delay("200 millis"))
const program = elapsed(task)
Effect.runPromise(program).then(console.log)
/*
Output:
Elapsed: 204
some task
*/Within the generator, we use yield* to invoke effects and bind their results to variables. This eliminates the nesting and provides a more readable and sequential code structure.
The generator style in Effect uses a more linear and sequential flow of execution, resembling traditional imperative programming languages. This makes the code easier to read and understand, especially for developers who are more familiar with imperative programming paradigms.
Dual APIs
Overview
When you're working with APIs in the Effect ecosystem, you may come across two different ways to use the same API. These two ways are called the "data-last" and "data-first" variants.
When an API supports both variants, we call them "dual" APIs.
Here's an illustration of these two variants using Effect.map.
Effect.map as a dual API
The Effect.map function is defined with two TypeScript overloads. The terms "data-last" and "data-first" refer to the position of the self argument (also known as the "data") in the signatures of the two overloads:
declare const map: {
// ┌─── data-last
// ▼
<A, B>(f: (a: A) => B): <E, R>(self: Effect<A, E, R>) => Effect<B, E, R>
// ┌─── data-first
// ▼
<A, E, R, B>(self: Effect<A, E, R>, f: (a: A) => B): Effect<B, E, R>
}data-last
In the first overload, the self argument comes last:
declare const map: <A, B>(
f: (a: A) => B
) => <E, R>(self: Effect<A, E, R>) => Effect<B, E, R>This version is commonly used with the pipe function. You start by passing the Effect as the initial argument to pipe and then chain transformations like Effect.map:
Example (Using data-last with pipe)
const mappedEffect = pipe(effect, Effect.map(func))This style is helpful when chaining multiple transformations, making the code easier to follow in a pipeline format:
pipe(effect, Effect.map(func1), Effect.map(func2), ...)data-first
In the second overload, the self argument comes first:
declare const map: <A, E, R, B>(
self: Effect<A, E, R>,
f: (a: A) => B
) => Effect<B, E, R>This form doesn't require pipe. Instead, you provide the Effect directly as the first argument:
Example (Using data-first without pipe)
const mappedEffect = Effect.map(effect, func)This version works well when you only need to perform a single operation on the Effect.
Tip: Choosing Between Styles
Both overloads achieve the same result. Choose the one that best suits your coding style and enhances readability for your team.
Guidelines
Overview
Using runMain
In Effect, runMain is the primary entry point for executing an Effect application on Node.js.
Example (Running an Effect Application with Graceful Teardown)
const program = pipe(
Effect.addFinalizer(() => Console.log("Application is about to exit!")),
Effect.andThen(Console.log("Application started!")),
Effect.andThen(
Effect.repeat(Console.log("still alive..."), {
schedule: Schedule.spaced("1 second")
})
),
Effect.scoped
)
// No graceful teardown on CTRL+C
// Effect.runPromise(program)
// Use NodeRuntime.runMain for graceful teardown on CTRL+C
NodeRuntime.runMain(program)
/*
Output:
Application started!
still alive...
still alive...
still alive...
still alive...
^C <-- CTRL+C
Application is about to exit!
*/The runMain function handles finding and interrupting all fibers. Internally, it observes the fiber and listens for sigint signals, ensuring a graceful shutdown of the application when interrupted (e.g., using CTRL+C).
Tip: Graceful Teardown
Ensure the teardown logic is placed in the main effect. If the fiber running the application or server is interrupted, runMain ensures that all resources are properly released.
Versions for Different Platforms
Effect provides versions of runMain tailored for different platforms:
| Platform | Runtime Version | Import Path |
|---|---|---|
| Node.js | NodeRuntime.runMain | @effect/platform-node |
| Bun | BunRuntime.runMain | @effect/platform-bun |
| Browser | BrowserRuntime.runMain | @effect/platform-browser |
Avoid Tacit Usage
Avoid using tacit (point-free) function calls, such as Effect.map(fn), or using flow from the effect/Function module.
In Effect, it's generally safer to write functions explicitly:
Effect.map((x) => fn(x))rather than in a point-free style:
Effect.map(fn)While tacit functions may be appealing for their brevity, they can introduce a number of problems:
- Using tacit functions, particularly when dealing with optional parameters, can be unsafe. For example, if a function has overloads, writing it in a tacit style may erase all generics, resulting in bugs. Check out this X thread for more details: link to thread.
- Tacit usage can also compromise TypeScript's ability to infer types, potentially causing unexpected errors. This isn't just a matter of style but a way to avoid subtle mistakes that can arise from type inference issues.
- Additionally, stack traces might not be as clear when tacit usage is employed.
Avoiding tacit usage is a simple precaution that makes your code more reliable.
Pattern Matching
Overview
Pattern matching is a method that allows developers to handle intricate conditions within a single, concise expression. It simplifies code, making it more concise and easier to understand. Additionally, it includes a process called exhaustiveness checking, which helps to ensure that no possible case has been overlooked.
Originating from functional programming languages, pattern matching stands as a powerful technique for code branching. It often offers a more potent and less verbose solution compared to imperative alternatives such as if/else or switch statements, particularly when dealing with complex conditions.
Although not yet a native feature in JavaScript, there's an ongoing tc39 proposal in its early stages to introduce pattern matching to JavaScript. However, this proposal is at stage 1 and might take several years to be implemented. Nonetheless, developers can implement pattern matching in their codebase. The effect/Match module provides a reliable, type-safe pattern matching implementation that is available for immediate use.
Example (Handling Different Data Types with Pattern Matching)
// Simulated dynamic input that can be a string or a number
const input: string | number = "some input"
// ┌─── string
// ▼
const result = Match.value(input).pipe(
// Match if the value is a number
Match.when(Match.number, (n) => `number: ${n}`),
// Match if the value is a string
Match.when(Match.string, (s) => `string: ${s}`),
// Ensure all possible cases are covered
Match.exhaustive
)
console.log(result)
// Output: "string: some input"How Pattern Matching Works
Pattern matching follows a structured process:
1. Creating a matcher. Define a Matcher that operates on either a specific type or value.
2. Defining patterns. Use combinators such as Match.when, Match.not, and Match.tag to specify matching conditions.
3. Completing the match. Apply a finalizer such as Match.exhaustive, Match.orElse, or Match.option to determine how unmatched cases should be handled.
Creating a matcher
You can create a Matcher using either:
Match.type<T>(): Matches against a specific type.Match.value(value): Matches against a specific value.
Matching by Type
The Match.type constructor defines a Matcher that operates on a specific type. Once created, you can use patterns like Match.when to define conditions for handling different cases.
Example (Matching Numbers and Strings)
// Create a matcher for values that are either strings or numbers
//
// ┌─── (u: string | number) => string
// ▼
const match = Match.type<string | number>().pipe(
// Match when the value is a number
Match.when(Match.number, (n) => `number: ${n}`),
// Match when the value is a string
Match.when(Match.string, (s) => `string: ${s}`),
// Ensure all possible cases are handled
Match.exhaustive
)
console.log(match(0))
// Output: "number: 0"
console.log(match("hello"))
// Output: "string: hello"Matching by Value
Instead of creating a matcher for a type, you can define one directly from a specific value using Match.value.
Example (Matching an Object by Property)
const input = { name: "John", age: 30 }
// Create a matcher for the specific object
const result = Match.value(input).pipe(
// Match when the 'name' property is "John"
Match.when(
{ name: "John" },
(user) => `${user.name} is ${user.age} years old`
),
// Provide a fallback if no match is found
Match.orElse(() => "Oh, not John")
)
console.log(result)
// Output: "John is 30 years old"Enforcing a Return Type
You can use Match.withReturnType<T>() to ensure that all branches return a specific type.
Example (Validating Return Type Consistency)
This example enforces that every matching branch returns a string.
const match = Match.type<{ a: number } | { b: string }>().pipe(
// Ensure all branches return a string
Match.withReturnType<string>(),
// ❌ Type error: returns a number
// @errors: 2322
Match.when({ a: Match.number }, (_) => _.a),
// ✅ Correct: returns a string
Match.when({ b: Match.string }, (_) => _.b),
Match.exhaustive
)Note: Must Be First in the Pipeline
The Match.withReturnType<T>() call must be the first instruction in the pipeline. If placed later, TypeScript will not properly enforce return type consistency.
Defining patterns
when
The Match.when function allows you to define conditions for matching values. It supports both direct value comparisons and predicate functions.
Example (Matching with Values and Predicates)
// Create a matcher for objects with an "age" property
const match = Match.type<{ age: number }>().pipe(
// Match when age is greater than 18
Match.when({ age: (age) => age > 18 }, (user) => `Age: ${user.age}`),
// Match when age is exactly 18
Match.when({ age: 18 }, () => "You can vote"),
// Fallback case for all other ages
Match.orElse((user) => `${user.age} is too young`)
)
console.log(match({ age: 20 }))
// Output: "Age: 20"
console.log(match({ age: 18 }))
// Output: "You can vote"
console.log(match({ age: 4 }))
// Output: "4 is too young"not
The Match.not function allows you to exclude specific values while matching all others.
Example (Ignoring a Specific Value)
// Create a matcher for string or number values
const match = Match.type<string | number>().pipe(
// Match any value except "hi", returning "ok"
Match.not("hi", () => "ok"),
// Fallback case for when the value is "hi"
Match.orElse(() => "fallback")
)
console.log(match("hello"))
// Output: "ok"
console.log(match("hi"))
// Output: "fallback"tag
The Match.tag function allows pattern matching based on the _tag field in a Discriminated Union. You can specify multiple tags to match within a single pattern.
Example (Matching a Discriminated Union by Tag)
type Event =
| { readonly _tag: "fetch" }
| { readonly _tag: "success"; readonly data: string }
| { readonly _tag: "error"; readonly error: Error }
| { readonly _tag: "cancel" }
// Create a Matcher for Either<number, string>
const match = Match.type<Event>().pipe(
// Match either "fetch" or "success"
Match.tag("fetch", "success", () => `Ok!`),
// Match "error" and extract the error message
Match.tag("error", (event) => `Error: ${event.error.message}`),
// Match "cancel"
Match.tag("cancel", () => "Cancelled"),
Match.exhaustive
)
console.log(match({ _tag: "success", data: "Hello" }))
// Output: "Ok!"
console.log(match({ _tag: "error", error: new Error("Oops!") }))
// Output: "Error: Oops!"Caution: Tag Field Naming Convention
The Match.tag function relies on the convention within the Effect ecosystem of naming the tag field as "_tag". Ensure that your discriminated unions follow this naming convention for proper functionality.
Built-in Predicates
The Match module provides built-in predicates for common types, such as Match.number, Match.string, and Match.boolean. These predicates simplify the process of matching against primitive types.
Example (Using Built-in Predicates for Property Keys)
const matchPropertyKey = Match.type<PropertyKey>().pipe(
// Match when the value is a number
Match.when(Match.number, (n) => `Key is a number: ${n}`),
// Match when the value is a string
Match.when(Match.string, (s) => `Key is a string: ${s}`),
// Match when the value is a symbol
Match.when(Match.symbol, (s) => `Key is a symbol: ${String(s)}`),
// Ensure all possible cases are handled
Match.exhaustive
)
console.log(matchPropertyKey(42))
// Output: "Key is a number: 42"
console.log(matchPropertyKey("username"))
// Output: "Key is a string: username"
console.log(matchPropertyKey(Symbol("id")))
// Output: "Key is a symbol: Symbol(id)"| Predicate | Description |
|---|---|
Match.string | Matches values of type string. |
Match.nonEmptyString | Matches non-empty strings. |
Match.number | Matches values of type number. |
Match.boolean | Matches values of type boolean. |
Match.bigint | Matches values of type bigint. |
Match.symbol | Matches values of type symbol. |
Match.date | Matches values that are instances of Date. |
Match.record | Matches objects where keys are string or symbol and values are unknown. |
Match.null | Matches the value null. |
Match.undefined | Matches the value undefined. |
Match.defined | Matches any defined (non-null and non-undefined) value. |
Match.any | Matches any value without restrictions. |
Match.is(...values) | Matches a specific set of literal values (e.g., Match.is("a", 42, true)). |
Match.instanceOf(Class) | Matches instances of a given class. |
Completing the match
exhaustive
The Match.exhaustive method finalizes the pattern matching process by ensuring that all possible cases are accounted for. If any case is missing, TypeScript will produce a type error. This is particularly useful when working with unions, as it helps prevent unintended gaps in pattern matching.
Example (Ensuring All Cases Are Covered)
// Create a matcher for string or number values
const match = Match.type<string | number>().pipe(
// Match when the value is a number
Match.when(Match.number, (n) => `number: ${n}`),
// Mark the match as exhaustive, ensuring all cases are handled
// TypeScript will throw an error if any case is missing
// @errors: 2345
Match.exhaustive
)orElse
The Match.orElse method defines a fallback value to return when no other patterns match. This ensures that the matcher always produces a valid result.
Example (Providing a Default Value When No Patterns Match)
// Create a matcher for string or number values
const match = Match.type<string | number>().pipe(
// Match when the value is "a"
Match.when("a", () => "ok"),
// Fallback when no patterns match
Match.orElse(() => "fallback")
)
console.log(match("a"))
// Output: "ok"
console.log(match("b"))
// Output: "fallback"option
Match.option wraps the match result in an Option. If a match is found, it returns Some(value), otherwise, it returns None.
Example (Extracting a User Role with Option)
type User = { readonly role: "admin" | "editor" | "viewer" }
// Create a matcher to extract user roles
const getRole = Match.type<User>().pipe(
Match.when({ role: "admin" }, () => "Has full access"),
Match.when({ role: "editor" }, () => "Can edit content"),
Match.option // Wrap the result in an Option
)
console.log(getRole({ role: "admin" }))
// Output: { _id: 'Option', _tag: 'Some', value: 'Has full access' }
console.log(getRole({ role: "viewer" }))
// Output: { _id: 'Option', _tag: 'None' }either
The Match.either method wraps the result in an Either, providing a structured way to distinguish between matched and unmatched cases. If a match is found, it returns Right(value), otherwise, it returns Left(no match).
Example (Extracting a User Role with Either)
type User = { readonly role: "admin" | "editor" | "viewer" }
// Create a matcher to extract user roles
const getRole = Match.type<User>().pipe(
Match.when({ role: "admin" }, () => "Has full access"),
Match.when({ role: "editor" }, () => "Can edit content"),
Match.either // Wrap the result in an Either
)
console.log(getRole({ role: "admin" }))
// Output: { _id: 'Either', _tag: 'Right', right: 'Has full access' }
console.log(getRole({ role: "viewer" }))
// Output: { _id: 'Either', _tag: 'Left', left: { role: 'viewer' } }Equivalence
Overview
The Equivalence module provides a way to define equivalence relations between values in TypeScript. An equivalence relation is a binary relation that is reflexive, symmetric, and transitive, establishing a formal notion of when two values should be considered equivalent.
What is Equivalence?
An Equivalence<A> represents a function that compares two values of type A and determines if they are equivalent. This is more flexible and customizable than simple equality checks using ===.
Here's the structure of an Equivalence:
interface Equivalence<A> {
(self: A, that: A): boolean
}Using Built-in Equivalences
The module provides several built-in equivalence relations for common data types:
| Equivalence | Description |
|---|---|
string | Uses strict equality (===) for strings |
number | Uses strict equality (===) for numbers |
boolean | Uses strict equality (===) for booleans |
symbol | Uses strict equality (===) for symbols |
bigint | Uses strict equality (===) for bigints |
Date | Compares Date objects by their timestamps |
Example (Using Built-in Equivalences)
console.log(Equivalence.string("apple", "apple"))
// Output: true
console.log(Equivalence.string("apple", "orange"))
// Output: false
console.log(Equivalence.Date(new Date(2023, 1, 1), new Date(2023, 1, 1)))
// Output: true
console.log(Equivalence.Date(new Date(2023, 1, 1), new Date(2023, 10, 1)))
// Output: falseDeriving Equivalences
For more complex data structures, you may need custom equivalences. The Equivalence module lets you derive new Equivalence instances from existing ones with the Equivalence.mapInput function.
Example (Creating a Custom Equivalence for Objects)
interface User {
readonly id: number
readonly name: string
}
// Create an equivalence that compares User objects based only on the id
const equivalence = Equivalence.mapInput(
Equivalence.number, // Base equivalence for comparing numbers
(user: User) => user.id // Function to extract the id from a User
)
// Compare two User objects: they are equivalent if their ids are the same
console.log(equivalence({ id: 1, name: "Alice" }, { id: 1, name: "Al" }))
// Output: trueThe Equivalence.mapInput function takes two arguments:
1. The existing Equivalence you want to use as a base (Equivalence.number in this case, for comparing numbers). 2. A function that extracts the value used for the equivalence check from your data structure ((user: User) => user.id in this case).
Order
Overview
The Order module provides a way to compare values and determine their order. It defines an interface Order<A> which represents a single function for comparing two values of type A. The function returns -1, 0, or 1, indicating whether the first value is less than, equal to, or greater than the second value.
Here's the basic structure of an Order:
interface Order<A> {
(first: A, second: A): -1 | 0 | 1
}Using the Built-in Orders
The Order module comes with several built-in comparators for common data types:
| Order | Description |
|---|---|
string | Used for comparing strings. |
number | Used for comparing numbers. |
bigint | Used for comparing big integers. |
Date | Used for comparing Date objects. |
Example (Using Built-in Comparators)
console.log(Order.string("apple", "banana"))
// Output: -1, as "apple" < "banana"
console.log(Order.number(1, 1))
// Output: 0, as 1 = 1
console.log(Order.bigint(2n, 1n))
// Output: 1, as 2n > 1nSorting Arrays
You can sort arrays using these comparators. The Array module offers a sort function that sorts arrays without altering the original one.
Example (Sorting Arrays with Order)
const strings = ["b", "a", "d", "c"]
const result = Array.sort(strings, Order.string)
console.log(strings) // Original array remains unchanged
// Output: [ 'b', 'a', 'd', 'c' ]
console.log(result) // Sorted array
// Output: [ 'a', 'b', 'c', 'd' ]You can also use an Order as a comparator with JavaScript's native Array.sort method, but keep in mind that this will modify the original array.
Example (Using Order with Native Array.prototype.sort)
const strings = ["b", "a", "d", "c"]
strings.sort(Order.string) // Modifies the original array
console.log(strings)
// Output: [ 'a', 'b', 'c', 'd' ]Deriving Orders
For more complex data structures, you may need custom sorting rules. The Order module lets you derive new Order instances from existing ones with the Order.mapInput function.
Example (Creating a Custom Order for Objects)
Imagine you have a list of Person objects, and you want to sort them by their names in ascending order. To achieve this, you can create a custom Order.
// Define the Person interface
interface Person {
readonly name: string
readonly age: number
}
// Create a custom order to sort Person objects by name in ascending order
//
// ┌─── Order<Person>
// ▼
const byName = Order.mapInput(
Order.string,
(person: Person) => person.name
)The Order.mapInput function takes two arguments:
1. The existing Order you want to use as a base (Order.string in this case, for comparing strings). 2. A function that extracts the value you want to use for sorting from your data structure ((person: Person) => person.name in this case).
Once you have defined your custom Order, you can apply it to sort an array of Person objects:
Example (Sorting Objects Using a Custom Order)
// Define the Person interface
interface Person {
readonly name: string
readonly age: number
}
// Create a custom order to sort Person objects by name in ascending order
const byName = Order.mapInput(
Order.string,
(person: Person) => person.name
)
const persons: ReadonlyArray<Person> = [
{ name: "Charlie", age: 22 },
{ name: "Alice", age: 25 },
{ name: "Bob", age: 30 }
]
// Sort persons array using the custom order
const sortedPersons = Array.sort(persons, byName)
console.log(sortedPersons)
/*
Output:
[
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 },
{ name: 'Charlie', age: 22 }
]
*/Combining Orders
The Order module lets you combine multiple Order instances to create complex sorting rules. This is useful when sorting by multiple properties.
Example (Sorting by Multiple Criteria)
Imagine you have a list of people, each represented by an object with a name and an age. You want to sort this list first by name and then, for individuals with the same name, by age.
// Define the Person interface
interface Person {
readonly name: string
readonly age: number
}
// Create an Order to sort people by their names in ascending order
const byName = Order.mapInput(
Order.string,
(person: Person) => person.name
)
// Create an Order to sort people by their ages in ascending order
const byAge = Order.mapInput(Order.number, (person: Person) => person.age)
// Combine orders to sort by name, then by age
const byNameAge = Order.combine(byName, byAge)
const result = Array.sort(
[
{ name: "Bob", age: 20 },
{ name: "Alice", age: 18 },
{ name: "Bob", age: 18 }
],
byNameAge
)
console.log(result)
/*
Output:
[
{ name: 'Alice', age: 18 }, // Sorted by name
{ name: 'Bob', age: 18 }, // Sorted by age within the same name
{ name: 'Bob', age: 20 }
]
*/Additional Useful Functions
The Order module provides additional functions for common comparison operations, making it easier to work with ordered values.
Reversing Order
Order.reverse inverts the order of comparison. If you have an Order for ascending values, reversing it makes it descending.
Example (Reversing an Order)
const ascendingOrder = Order.number
const descendingOrder = Order.reverse(ascendingOrder)
console.log(ascendingOrder(1, 3))
// Output: -1 (1 < 3 in ascending order)
console.log(descendingOrder(1, 3))
// Output: 1 (1 > 3 in descending order)Comparing Values
These functions allow you to perform simple comparisons between values:
| API | Description |
|---|---|
lessThan | Checks if one value is strictly less than another. |
greaterThan | Checks if one value is strictly greater than another. |
lessThanOrEqualTo | Checks if one value is less than or equal to another. |
greaterThanOrEqualTo | Checks if one value is greater than or equal to another. |
Example (Using Comparison Functions)
console.log(Order.lessThan(Order.number)(1, 2))
// Output: true (1 < 2)
console.log(Order.greaterThan(Order.number)(5, 3))
// Output: true (5 > 3)
console.log(Order.lessThanOrEqualTo(Order.number)(2, 2))
// Output: true (2 <= 2)
console.log(Order.greaterThanOrEqualTo(Order.number)(4, 4))
// Output: true (4 >= 4)Finding Minimum and Maximum
The Order.min and Order.max functions return the minimum or maximum value between two values, considering the order.
Example (Finding Minimum and Maximum Numbers)
console.log(Order.min(Order.number)(3, 1))
// Output: 1 (1 is the minimum)
console.log(Order.max(Order.number)(5, 8))
// Output: 8 (8 is the maximum)Clamping Values
Order.clamp restricts a value within a given range. If the value is outside the range, it is adjusted to the nearest bound.
Example (Clamping Numbers to a Range)
// Define a function to clamp numbers between 20 and 30
const clampNumbers = Order.clamp(Order.number)({
minimum: 20,
maximum: 30
})
// Value 26 is within the range [20, 30], so it remains unchanged
console.log(clampNumbers(26))
// Output: 26
// Value 10 is below the minimum bound, so it is clamped to 20
console.log(clampNumbers(10))
// Output: 20
// Value 40 is above the maximum bound, so it is clamped to 30
console.log(clampNumbers(40))
// Output: 30Checking Value Range
Order.between checks if a value falls within a specified inclusive range.
Example (Checking if Numbers Fall Within a Range)
// Create a function to check if numbers are between 20 and 30
const betweenNumbers = Order.between(Order.number)({
minimum: 20,
maximum: 30
})
// Value 26 falls within the range [20, 30], so it returns true
console.log(betweenNumbers(26))
// Output: true
// Value 10 is below the minimum bound, so it returns false
console.log(betweenNumbers(10))
// Output: false
// Value 40 is above the maximum bound, so it returns false
console.log(betweenNumbers(40))
// Output: falseEqual
Overview
The Equal module provides a simple and convenient way to define and check for equality between two values in TypeScript.
Here are some key reasons why Effect exports an Equal module:
1. Value-Based Equality: JavaScript's native equality operators (=== and ==) check for equality by reference, meaning they compare objects based on their memory addresses rather than their content. This behavior can be problematic when you want to compare objects with the same values but different references. The Equal module offers a solution by allowing developers to define custom equality checks based on the values of objects.
2. Custom Equality: The Equal module enables developers to implement custom equality checks for their data types and classes. This is crucial when you have specific requirements for determining when two objects should be considered equal. By implementing the Equal interface, developers can define their own equality logic.
3. Data Integrity: In some applications, maintaining data integrity is crucial. The ability to perform value-based equality checks ensures that identical data is not duplicated within collections like sets or maps. This can lead to more efficient memory usage and more predictable behavior.
4. Predictable Behavior: The Equal module promotes more predictable behavior when comparing objects. By explicitly defining equality criteria, developers can avoid unexpected results that may occur with JavaScript's default reference-based equality checks.
How to Perform Equality Checking in Effect
In Effect it's advisable to stop using JavaScript's === and == operators and instead rely on the Equal.equals function. This function can work with any data type that implements the Equal interface. Some examples of such data types include Option, Either, HashSet, and HashMap.
When you use Equal.equals and your objects do not implement the Equal interface, it defaults to using the === operator for object comparison:
Example (Using Equal.equals with Default Comparison)
// Two objects with identical properties and values
const a = { name: "Alice", age: 30 }
const b = { name: "Alice", age: 30 }
// Equal.equals falls back to the default '===' comparison
console.log(Equal.equals(a, b))
// Output: falseIn this example, a and b are two separate objects with the same contents. However, === considers them different because they occupy different memory locations. This behavior can lead to unexpected results when you want to compare values based on their content.
However, you can configure your models to ensure that Equal.equals behaves consistently with your custom equality checks. There are two alternative approaches:
1. Implementing the `Equal` Interface: This method is useful when you need to define your custom equality check.
2. Using the Data Module: For simple value equality, the Data module provides a more straightforward solution by automatically generating default implementations for Equal.
Let's explore both.
Implementing the Equal Interface
To create custom equality behavior, you can implement the Equal interface in your models. This interface extends the Hash interface from the Hash module.
Example (Implementing Equal and Hash for a Custom Class)
class Person implements Equal.Equal {
constructor(
readonly id: number, // Unique identifier
readonly name: string,
readonly age: number
) {}
// Define equality based on id, name, and age
[Equal.symbol](that: Equal.Equal): boolean {
if (that instanceof Person) {
return (
Equal.equals(this.id, that.id) &&
Equal.equals(this.name, that.name) &&
Equal.equals(this.age, that.age)
)
}
return false
}
// Generate a hash code based on the unique id
[Hash.symbol](): number {
return Hash.hash(this.id)
}
}In the above code, we define a custom equality function [Equal.symbol] and a hash function [Hash.symbol] for the Person class. The Hash interface optimizes equality checks by comparing hash values instead of the objects themselves. When you use the Equal.equals function to compare two objects, it first checks if their hash values are equal. If not, it quickly determines that the objects are not equal, avoiding the need for a detailed property-by-property comparison.
Once you've implemented the Equal interface, you can utilize the Equal.equals function to check for equality using your custom logic.
Example (Comparing Person Instances)
class Person implements Equal.Equal {
constructor(
readonly id: number, // Unique identifier for each person
readonly name: string,
readonly age: number
) {}
// Defines equality based on id, name, and age
[Equal.symbol](that: Equal.Equal): boolean {
if (that instanceof Person) {
return (
Equal.equals(this.id, that.id) &&
Equal.equals(this.name, that.name) &&
Equal.equals(this.age, that.age)
)
}
return false
}
// Generates a hash code based primarily on the unique id
[Hash.symbol](): number {
return Hash.hash(this.id)
}
}
const alice = new Person(1, "Alice", 30)
console.log(Equal.equals(alice, new Person(1, "Alice", 30)))
// Output: true
const bob = new Person(2, "Bob", 40)
console.log(Equal.equals(alice, bob))
// Output: falseIn this code, the equality check returns true when comparing alice to a new Person object with identical property values and false when comparing alice to bob due to their differing property values.
Simplifying Equality with the Data Module
Implementing both Equal and Hash can become cumbersome when all you need is straightforward value equality checks. Luckily, the Data module provides a simpler solution. It offers APIs that automatically generate default implementations for both Equal and Hash.
Example (Using Data.struct for Equality Checks)
const alice = Data.struct({ id: 1, name: "Alice", age: 30 })
const bob = Data.struct({ id: 2, name: "Bob", age: 40 })
console.log(
Equal.equals(alice, Data.struct({ id: 1, name: "Alice", age: 30 }))
)
// Output: true
console.log(Equal.equals(alice, { id: 1, name: "Alice", age: 30 }))
// Output: false
console.log(Equal.equals(alice, bob))
// Output: falseIn this example, we use the Data.struct function to create structured data objects and check their equality using Equal.equals. The Data module simplifies the process by providing a default implementation for both Equal and Hash, allowing you to focus on comparing values without the need for explicit implementations.
The Data module isn't limited to just structs. It can handle various data types, including tuples, arrays, and records. If you're curious about how to leverage its full range of features, you can explore the Data module documentation.
Working with Collections
JavaScript's built-in Set and Map can be a bit tricky when it comes to checking equality:
Example (Native Set with Reference-Based Equality)
const set = new Set()
// Adding two objects with the same content to the set
set.add({ name: "Alice", age: 30 })
set.add({ name: "Alice", age: 30 })
// Even though the objects have identical values, they are treated
// as different elements because JavaScript compares objects by reference,
// not by value.
console.log(set.size)
// Output: 2Even though the two elements in the set have the same values, the set contains two elements. Why? JavaScript's Set checks for equality by reference, not by values.
To perform value-based equality checks, you'll need to use the Hash* collection types available in the effect package. These collection types, such as HashSet and HashMap, provide support for the Equal interface.
HashSet
When you use the HashSet, it correctly handles value-based equality checks. In the following example, even though you're adding two objects with the same values, the HashSet treats them as a single element.
Example (Using HashSet for Value-Based Equality)
// Creating a HashSet with objects that implement the Equal interface
const set = HashSet.empty().pipe(
HashSet.add(Data.struct({ name: "Alice", age: 30 })),
HashSet.add(Data.struct({ name: "Alice", age: 30 }))
)
// HashSet recognizes them as equal, so only one element is stored
console.log(HashSet.size(set))
// Output: 1Note: It's crucial to use elements that implement the Equal interface, either by implementing custom equality checks or by using the Data module. This ensures proper functionality when working with HashSet. Without this, you'll encounter the same behavior as the native Set data type:
Example (Reference-Based Equality in HashSet)
// Creating a HashSet with objects that do NOT implement
// the Equal interface
const set = HashSet.empty().pipe(
HashSet.add({ name: "Alice", age: 30 }),
HashSet.add({ name: "Alice", age: 30 })
)
// Since these objects are compared by reference,
// HashSet considers them different
console.log(HashSet.size(set))
// Output: 2In this case, without using the Data module alongside HashSet, you'll experience the same behavior as the native Set data type. The set contains two elements because it checks for equality by reference, not by values.
HashMap
When working with the HashMap, you have the advantage of comparing keys by their values instead of their references. This is particularly helpful in scenarios where you want to associate values with keys based on their content.
Example (Value-Based Key Comparisons with HashMap)
// Adding two objects with identical values as keys
const map = HashMap.empty().pipe(
HashMap.set(Data.struct({ name: "Alice", age: 30 }), 1),
HashMap.set(Data.struct({ name: "Alice", age: 30 }), 2)
)
console.log(HashMap.size(map))
// Output: 1
// Retrieve the value associated with a key
console.log(HashMap.get(map, Data.struct({ name: "Alice", age: 30 })))
/*
Output:
{ _id: 'Option', _tag: 'Some', value: 2 }
*/In this code snippet, HashMap is used to create a map where the keys are objects constructed with Data.struct. These objects contain identical values, which would usually create separate entries in a regular JavaScript Map because the default comparison is reference-based.
HashMap, however, uses value-based comparison, meaning the two objects with identical content are treated as the same key. Thus, when we add both objects, the second key-value pair overrides the first, resulting in a single entry in the map.
Hash
Overview
The Hash interface is closely tied to the Equal interface and serves a supportive role in optimizing equality checks by providing a mechanism for hashing. Hashing is an important step in the efficient determination of equality between two values, particularly when used with data structures like hash tables.
Role of Hash in Equality Checking
The primary purpose of the Hash interface is to provide a quick and efficient way to determine if two values are definitely not equal, thereby complementing the Equal interface. When two values implement the Equal interface, their hash values (computed using the Hash interface) are compared first:
- Different Hash Values: If the hash values are different, it is guaranteed that the values themselves are different. This quick check allows the system to avoid a potentially expensive equality check.
- Same Hash Values: If the hash values are the same, it does not guarantee that the values are equal, only that they might be. In this case, a more thorough comparison using the Equal interface is performed to determine actual equality.
This method dramatically speeds up the equality checking process, especially in collections where quick look-up and insertion times are crucial, such as in hash sets or hash maps.
Implementing the Hash Interface
Consider a scenario where you have a custom Person class, and you want to check if two instances are equal based on their properties. By implementing both the Equal and Hash interfaces, you can efficiently manage these checks:
Example (Implementing Equal and Hash for a Custom Class)
class Person implements Equal.Equal {
constructor(
readonly id: number, // Unique identifier
readonly name: string,
readonly age: number
) {}
// Define equality based on id, name, and age
[Equal.symbol](that: Equal.Equal): boolean {
if (that instanceof Person) {
return (
Equal.equals(this.id, that.id) &&
Equal.equals(this.name, that.name) &&
Equal.equals(this.age, that.age)
)
}
return false
}
// Generate a hash code based on the unique id
[Hash.symbol](): number {
return Hash.hash(this.id)
}
}
const alice = new Person(1, "Alice", 30)
console.log(Equal.equals(alice, new Person(1, "Alice", 30)))
// Output: true
const bob = new Person(2, "Bob", 40)
console.log(Equal.equals(alice, bob))
// Output: falseExplanation:
- The
[Equal.symbol]method determines equality by comparing theid,name, andagefields ofPersoninstances. This approach ensures that the equality check is comprehensive and considers all relevant attributes. - The
[Hash.symbol]method computes a hash code using theidof the person. This value is used to quickly differentiate between instances in hashing operations, optimizing the performance of data structures that utilize hashing. - The equality check returns
truewhen comparingaliceto a newPersonobject with identical property values andfalsewhen comparingalicetobobdue to their differing property values.
Common Mistakes
Incorrect (raw string types for identifiers):
const getUser = (id: string) => // Any string accepted
db.findUser(id)
getUser("not-a-valid-id") // No compile errorCorrect (branded types for type-safe identifiers):
import { Brand } from "effect"
type UserId = string & Brand.Brand<"UserId">
const UserId = Brand.nominal<UserId>()
const getUser = (id: UserId) => db.findUser(id)
getUser(UserId("user-123")) // Only branded values acceptedThink in Effect: The Paradigm Shift
Effect is not a utility library you add to existing code. It is a different way of writing programs, inspired by Scala's ZIO, Haskell's IO monad, and algebraic effects research. Using Effect APIs without understanding the paradigm produces code that compiles but misses every architectural benefit the library provides.
Read this file BEFORE any API reference. It teaches you how to think. The other reference files teach you what to type.
---
The Five Mental Model Shifts
1. Programs Are Values, Not Instructions
In typical TypeScript, code executes as you write it. A fetch() call fires immediately. A new Promise(...) starts running the moment it's constructed.
In Effect, code is a description of what should happen. Nothing executes until you explicitly run it at the program boundary with Effect.runPromise or Effect.runSync.
This is the single most important concept. Everything else follows from it.
// This does NOT make an HTTP call. It describes one.
const fetchUser = Effect.tryPromise(() => fetch("/api/user"))
// You can pass it around, compose it, retry it, race it — nothing has happened yet.
const withRetry = Effect.retry(fetchUser, { times: 3 })
const withTimeout = Effect.timeout(withRetry, "5 seconds")
// NOW it executes — at the boundary, once, with all the composition applied.
Effect.runPromise(withTimeout)Why this matters for your code: Place runPromise/runSync at the outermost edge of your application (the main function, the HTTP request handler, the CLI entry point). Never in the middle of business logic. If you're calling runPromise inside a service, you've broken the paradigm — you're executing a sub-program instead of composing descriptions.
2. The Type Signature Is the Architecture
Effect<Success, Error, Requirements> is not just a return type — it's a function's complete contract:
// What it produces What can go wrong What it needs to run
// ▼ ▼ ▼
Effect< User, NotFound | DbError, UserRepo | Logger >- A (Success): The value produced on success. Same as a Promise's resolved type.
- E (Error): Every failure mode, tracked by the compiler. Not
unknown, notError—
the exact union of things that can go wrong. If you add a new failure mode, every caller's type changes, forcing you to handle it.
- R (Requirements): Every dependency needed to run this effect. Not imported globally —
declared in the type. If you need a database, the type says so. If you need a logger, the type says so.
Why this matters for your code: When you write a function that returns Effect<User, NotFound | DbError, UserRepo>, you've simultaneously written:
- The function's return type
- Its error documentation
- Its dependency manifest
The compiler enforces all three. You cannot run this effect without providing UserRepo. You cannot ignore NotFound. This is architecture enforced by types, not by convention.
3. Dependencies Are Declared, Not Imported
Typical TypeScript uses imports for dependencies:
// Typical TS — dependency is a global import, invisible in the type
import { prisma } from "./db"
export const getUser = async (id: string) => {
return prisma.user.findUnique({ where: { id } })
}
// Caller has no idea this touches a database. Can't test without mocking the import.Effect declares dependencies in the R channel and provides them via Layer:
// Effect — dependency is declared in the type, provided at the boundary
class UserRepo extends Context.Tag("UserRepo")<UserRepo, {
readonly findById: (id: string) => Effect.Effect<User, NotFound>
}>() {}
export const getUser = (id: string) =>
UserRepo.pipe(Effect.flatMap((repo) => repo.findById(id)))
// Type: Effect<User, NotFound, UserRepo>
// Caller SEES the dependency. Tests provide a different UserRepo.This is TypeScript's version of tagless final from Scala. You program against interfaces (Context.Tag), provide implementations at the edge (Layer), and the compiler ensures everything is wired before the program runs.
The architectural pattern:
Business logic (pure effects, declares R)
↓ composed with
Service interfaces (Context.Tag definitions)
↓ implemented by
Layers (concrete implementations — DB, HTTP, cache)
↓ provided at
Program boundary (main / request handler / test harness)4. Errors Are Data, Not Exceptions
Typical TypeScript scatters throw and try/catch throughout the codebase. Error types are unknown, catch blocks are defensive, and it's impossible to know what a function might throw without reading its entire implementation.
Effect treats errors as typed data flowing through the E channel:
// Define error types FIRST — before the happy path
class NotFound extends Data.TaggedError("NotFound")<{
readonly entity: string
readonly id: string
}> {}
class ValidationError extends Data.TaggedError("ValidationError")<{
readonly field: string
readonly message: string
}> {}
// Errors flow through the type system. The caller sees: Effect<User, NotFound | ValidationError>
const getUser = (id: string) =>
Effect.gen(function* () {
if (!isValidId(id)) yield* new ValidationError({ field: "id", message: "Invalid format" })
const user = yield* findUser(id)
if (!user) yield* new NotFound({ entity: "User", id })
return user
})The principle: define error types first, handle them last.
Don't catch errors inside services. Let them flow outward through the E channel. Handle them at boundaries (HTTP handler → map to status codes, CLI → map to exit codes, main → log and exit). This is the opposite of defensive programming — it's letting the type system do the work.
// At the HTTP boundary — the only place errors are handled
const handler = pipe(
getUser(id),
Effect.catchTag("NotFound", (e) => HttpResponse.json({ error: "Not found" }, { status: 404 })),
Effect.catchTag("ValidationError", (e) => HttpResponse.json({ error: e.message }, { status: 400 }))
)5. Compose Small Things, Don't Orchestrate Big Things
Typical TypeScript programs are orchestrated step-by-step:
// Imperative orchestration — hard to reuse, test, or modify
async function processOrder(orderId: string) {
const order = await getOrder(orderId)
const user = await getUser(order.userId)
await validateInventory(order.items)
const payment = await chargeCard(user.paymentMethod, order.total)
await sendConfirmation(user.email, order, payment)
return { order, payment }
}Effect programs are built by composing small, reusable pieces:
// Each step is an independent, testable, retryable effect
const processOrder = (orderId: string) =>
Effect.gen(function* () {
const order = yield* OrderService.pipe(Effect.flatMap((s) => s.get(orderId)))
const user = yield* UserService.pipe(Effect.flatMap((s) => s.get(order.userId)))
yield* InventoryService.pipe(Effect.flatMap((s) => s.validate(order.items)))
const payment = yield* PaymentService.pipe(Effect.flatMap((s) => s.charge(user.paymentMethod, order.total)))
yield* NotificationService.pipe(Effect.flatMap((s) => s.sendConfirmation(user.email, order, payment)))
return { order, payment }
})
// Type tells you everything: Effect<OrderResult, OrderNotFound | UserNotFound | InsufficientStock | PaymentFailed | EmailError, OrderService | UserService | InventoryService | PaymentService | NotificationService>Each service is independently testable, replaceable, and composable. The type signature is the dependency graph and error manifest combined.
---
Refactoring Recipes
When converting existing TypeScript code to Effect, apply these transformations systematically. The order matters — start with error types, then services, then wiring.
Recipe 1: async/await → Effect.gen
Incorrect (direct async/await translation that misses the paradigm):
// Just wrapping async in Effect — misses dependency injection and typed errors
const getUser = (id: string) =>
Effect.tryPromise(async () => {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error("Failed")
return res.json()
})Correct (idiomatic Effect with typed errors and services):
class UserNotFound extends Data.TaggedError("UserNotFound")<{ readonly id: string }> {}
class UserApiError extends Data.TaggedError("UserApiError")<{ readonly status: number }> {}
class UserApi extends Context.Tag("UserApi")<UserApi, {
readonly getById: (id: string) => Effect.Effect<User, UserNotFound | UserApiError>
}>() {}
// Implementation in a Layer
const UserApiLive = Layer.succeed(UserApi, {
getById: (id) =>
Effect.gen(function* () {
const res = yield* Effect.tryPromise({
try: () => fetch(`/api/users/${id}`),
catch: () => new UserApiError({ status: 0 })
})
if (res.status === 404) return yield* new UserNotFound({ id })
if (!res.ok) return yield* new UserApiError({ status: res.status })
return yield* Effect.tryPromise({
try: () => res.json() as Promise<User>,
catch: () => new UserApiError({ status: res.status })
})
})
})Recipe 2: throw → Effect.fail with tagged errors
Incorrect (throw inside Effect — creates untyped defects):
const divide = (a: number, b: number) =>
Effect.sync(() => {
if (b === 0) throw new Error("Division by zero") // Untyped defect!
return a / b
})
// Type: Effect<number, never, never> — the error is INVISIBLECorrect (Effect.fail with tagged error — tracked in the type):
class DivisionByZero extends Data.TaggedError("DivisionByZero")<{}> {}
const divide = (a: number, b: number): Effect.Effect<number, DivisionByZero> =>
b === 0 ? Effect.fail(new DivisionByZero()) : Effect.succeed(a / b)
// Type: Effect<number, DivisionByZero, never> — error is VISIBLERecipe 3: Global imports → Context.Tag + Layer
Incorrect (importing singletons — untestable, invisible dependencies):
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
export const getUser = (id: string) =>
Effect.tryPromise(() => prisma.user.findUnique({ where: { id } }))
// Type: Effect<User | null, UnknownException, never>
// The database dependency is INVISIBLE in the typeCorrect (Context.Tag service — testable, explicit dependency):
class Database extends Context.Tag("Database")<Database, {
readonly user: {
readonly findById: (id: string) => Effect.Effect<User, UserNotFound>
}
}>() {}
export const getUser = (id: string) =>
Database.pipe(Effect.flatMap((db) => db.user.findById(id)))
// Type: Effect<User, UserNotFound, Database>
// The database dependency is VISIBLE and SWAPPABLE
// Production layer
const DatabaseLive = Layer.effect(Database,
Effect.gen(function* () {
const prisma = new PrismaClient()
return {
user: {
findById: (id) =>
Effect.tryPromise({ try: () => prisma.user.findUnique({ where: { id } }), catch: () => new UserNotFound({ id }) })
.pipe(Effect.flatMap((u) => u ? Effect.succeed(u) : Effect.fail(new UserNotFound({ id }))))
}
}
})
)
// Test layer — no database needed
const DatabaseTest = Layer.succeed(Database, {
user: {
findById: (id) =>
id === "1" ? Effect.succeed({ id: "1", name: "Alice", email: "a@b.com" }) : Effect.fail(new UserNotFound({ id }))
}
})Recipe 4: try/finally → Effect.acquireUseRelease
Incorrect (manual cleanup that can leak on interruption):
const withConnection = Effect.gen(function* () {
const conn = yield* Effect.tryPromise(() => pool.connect())
try {
return yield* doWork(conn)
} finally {
conn.release() // Not Effect-aware — ignores interruption, not composable
}
})Correct (acquireUseRelease with guaranteed cleanup):
const withConnection = Effect.acquireUseRelease(
Effect.tryPromise({ try: () => pool.connect(), catch: (e) => new ConnectionError({ cause: e }) }),
(conn) => doWork(conn),
(conn) => Effect.sync(() => conn.release()) // Guaranteed to run, even on fiber interruption
)Recipe 5: Promise.all → Effect.all with structured concurrency
Incorrect (Promise.all without cancellation or error tracking):
const [user, orders, prefs] = await Promise.all([
getUser(id), // If this fails...
getOrders(id), // ...this keeps running wastefully
getPreferences(id) // ...and so does this
])Correct (Effect.all with automatic interruption):
const [user, orders, prefs] = yield* Effect.all(
[getUser(id), getOrders(id), getPreferences(id)],
{ concurrency: "unbounded" }
)
// If getUser fails, the other two are AUTOMATICALLY interrupted.
// Error type: UserNotFound | OrderError | PrefError (full union, tracked)Recipe 6: Class with injected deps → Layer.effect
Incorrect (class-based DI — constructor injection, new keyword):
class OrderService {
constructor(
private db: Database,
private payment: PaymentGateway,
private mailer: Mailer
) {}
async process(orderId: string) { /* ... */ }
}
// Wiring: new OrderService(new Database(...), new PaymentGateway(...), new Mailer(...))Correct (Layer composition — no classes, no new, no constructors):
class OrderService extends Context.Tag("OrderService")<OrderService, {
readonly process: (orderId: string) => Effect.Effect<Order, OrderError>
}>() {}
const OrderServiceLive = Layer.effect(OrderService,
Effect.gen(function* () {
const db = yield* Database
const payment = yield* PaymentGateway
const mailer = yield* Mailer
return {
process: (orderId) =>
Effect.gen(function* () {
const order = yield* db.getOrder(orderId)
yield* payment.charge(order)
yield* mailer.sendConfirmation(order)
return order
})
}
})
)
// Wiring: Layer composition, not constructor calls
const AppLive = OrderServiceLive.pipe(
Layer.provide(Layer.merge(DatabaseLive, PaymentGatewayLive)),
Layer.provide(MailerLive)
)---
Anti-Patterns
Don't wrap everything in Effect
Only wrap at system boundaries (I/O, external APIs, database calls). Pure computation stays as plain TypeScript:
// WRONG — unnecessary Effect wrapping
const add = (a: number, b: number) => Effect.succeed(a + b)
// RIGHT — plain function, used inside Effect.gen when needed
const add = (a: number, b: number) => a + b
const program = Effect.gen(function* () {
const x = yield* getNumber()
return add(x, 10) // No yield* needed — it's just a value
})Don't call runPromise inside services
runPromise is the program boundary. Calling it inside a service breaks composition — you lose error tracking, dependency tracking, and interruption:
// WRONG — runPromise inside a service breaks the Effect chain
const getUser = (id: string) =>
Effect.tryPromise(() =>
Effect.runPromise(someOtherEffect) // Breaks composition!
)
// RIGHT — compose effects, don't execute them
const getUser = (id: string) =>
someOtherEffect.pipe(
Effect.flatMap((result) => /* ... */)
)Don't catch errors too early
Let errors flow through the E channel to the boundary. Catching inside services hides failure modes from callers:
// WRONG — swallowing errors inside the service
const getUser = (id: string) =>
findUser(id).pipe(
Effect.catchAll(() => Effect.succeed(null)) // Caller can't distinguish "not found" from "db down"
)
// RIGHT — let errors propagate, handle at the boundary
const getUser = (id: string) => findUser(id)
// Type: Effect<User, NotFound | DbError, Database>
// The HTTP handler decides: NotFound → 404, DbError → 500Don't use generic Error — use tagged errors
Every distinct failure mode gets its own error class. This enables precise handling with catchTag:
// WRONG — generic Error, can't handle specifically
Effect.fail(new Error("user not found"))
Effect.fail(new Error("database connection failed"))
// Caller: catchAll or nothing. Can't distinguish the two.
// RIGHT — tagged errors, precise handling
class UserNotFound extends Data.TaggedError("UserNotFound")<{ readonly id: string }> {}
class DbConnectionFailed extends Data.TaggedError("DbConnectionFailed")<{ readonly host: string }> {}
// Caller: catchTag("UserNotFound", ...) vs catchTag("DbConnectionFailed", ...)Don't put business logic in Layer construction
Layers are for wiring — creating service instances and connecting dependencies. Business logic belongs in the service methods, not in the Layer factory:
// WRONG — business logic in Layer
const UserServiceLive = Layer.effect(UserService,
Effect.gen(function* () {
const db = yield* Database
const users = yield* db.loadAllUsers() // Business logic in wiring!
return { getUser: (id) => /* ... */ }
})
)
// RIGHT — Layer only wires, service methods contain logic
const UserServiceLive = Layer.effect(UserService,
Effect.gen(function* () {
const db = yield* Database
return {
getUser: (id) => db.findUser(id), // Logic here
listUsers: () => db.loadAllUsers() // And here
}
})
)---
Application Architecture: The Onion
An idiomatic Effect application has a layered structure, like an onion:
┌─────────────────────────────────────────────┐
│ Boundary (main / request handler) │
│ - Effect.runPromise / Effect.runFork │
│ - Layer.provide(AppLive) │
│ - Error → exit code / HTTP status mapping │
├─────────────────────────────────────────────┤
│ Services (business logic) │
│ - Pure effects: Effect.gen, pipe, flatMap │
│ - Declares R (dependencies via Context.Tag) │
│ - Declares E (typed errors via TaggedError) │
│ - NO imports of implementations │
│ - NO runPromise / runSync │
├─────────────────────────────────────────────┤
│ Service Interfaces (Context.Tag) │
│ - Defines the contract │
│ - Typed methods returning Effects │
│ - No implementation details │
├─────────────────────────────────────────────┤
│ Implementations (Layers) │
│ - Layer.succeed / Layer.effect │
│ - Concrete I/O: database, HTTP, filesystem │
│ - Composed with Layer.merge / Layer.provide │
├─────────────────────────────────────────────┤
│ Error Types (Data.TaggedError) │
│ - Defined per domain concept │
│ - Shared across services │
│ - Hierarchical when needed │
└─────────────────────────────────────────────┘Reading order when building an Effect application:
1. Define error types (Data.TaggedError for each failure mode) 2. Define service interfaces (Context.Tag with typed method signatures) 3. Write business logic (effects that use services, declare errors) 4. Implement services (Layer.effect with concrete I/O) 5. Compose layers (Layer.merge, Layer.provide) 6. Wire at the boundary (Effect.provide(AppLive), Effect.runPromise)
Reading order when refactoring existing code to Effect:
1. Identify all error cases → create Data.TaggedError for each 2. Identify all external dependencies (DB, HTTP, cache, config) → create Context.Tag for each 3. Rewrite functions as effects returning Effect<A, E, R> 4. Replace throw with Effect.fail, try/catch with catchTag 5. Replace imports with service access via yield* ServiceTag 6. Create Layer implementations for each service 7. Compose all layers and provide at the entry point 8. Move runPromise to the outermost boundary
---
When to Break the Rules
These patterns are defaults, not absolutes. Pragmatic exceptions exist:
- Small scripts: A 50-line CLI tool doesn't need full service/layer architecture. Use
Effect.gen with direct calls and Effect.runPromise at the top.
- Interop boundaries: When calling Effect code from non-Effect code (e.g., an Express
route handler), runPromise at that boundary is correct.
- Performance-critical inner loops: Pure computation in hot loops should stay as plain
TypeScript, not wrapped in Effect.
- Gradual adoption: You can use Effect for new code while keeping existing code as-is.
Wrap the boundary, don't rewrite everything at once.
The test: if adding Effect indirection doesn't buy you typed errors, testable dependencies, or composable retries/timeouts, it's just ceremony. Skip it for that specific case.
Related skills
FAQ
What does effect-ts do?
effect-ts: A skill for development. This provides functionality for development workflows.
When should I use effect-ts?
When you need to use effect-ts for development tasks, or when effect-ts: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
effect-ts.