UNPKG

@doeixd/effectively

Version:

Enhanced async/await effects for TypeScript applications. Effectively provides resilient error handling, dependency injection, retry logic, timeouts, circuit breakers, and resource cleanup for Node.js and browser environments. Build testable async workflo

1,151 lines (857 loc) β€’ 75.6 kB
![npm](https://img.shields.io/npm/v/@doeixd/effectively) ![license](https://img.shields.io/npm/l/@doeixd/effectively) # Effectively πŸš‚ **Build resilient TypeScript applications without the complexity.** Effectively is a lightweight toolkit that brings structure and safety to asynchronous TypeScript code. It feels like a natural extension of `async/await`, not a replacement for it. <br /> ## 🎯 Why Effectively? **The Problem:** Modern TypeScript applications face real challenges: - Unhandled errors crash production systems - Dependency injection becomes a tangled mess - Testing async code requires extensive mocking - Resource leaks from unclosed connections - No standard patterns for retries, timeouts, or circuit breakers **The Solution:** Effectively provides simple patterns for these problems without forcing you to learn a new programming paradigm. If you can write `async/await`, you can use Effectively. [See more](docs/why-effectively.md) <br /> ## πŸ“‘ Table of Contents - [πŸ“¦ Installation](#-installation) - [πŸš€ Building Intuition: A Getting Started Guide](#-building-intuition-a-getting-started-guide) - [πŸ’‘ Core Concepts](#-core-concepts) - [πŸ›‘οΈ Error Handling: A Dual Strategy](#️-error-handling-a-dual-strategy) - [πŸš€ Features](#-features) - [πŸ”§ Common Patterns](#-common-patterns) - [πŸ§ͺ Testing Your Workflows](#-testing-your-workflows) - [πŸ€” Comparisons & Where It Fits](#-comparisons--where-it-fits) - [🧠 Advanced Concepts](#-advanced-concepts) - [πŸ“š Guides & Deeper Dives](#-guides--deeper-dives) - [πŸ“‹ Best Practices](#-best-practices) - [⚠️ Common Pitfalls & Solutions](#️-common-pitfalls--solutions) - [🧰 API Reference](#-api-reference) <br /> ## πŸ“¦ Installation ```bash npm install @doeixd/effectively neverthrow ``` *Note: We highly recommend using `neverthrow` for typed error handling, it integrates well with effectively.* <br /> ## πŸš€ Building Intuition: A Getting Started Guide At its heart, Effectively is intuitively simple. Let's build your understanding from the ground up. #### Step 1: A Task is Just an Async Function In Effectively, everything starts with a simple idea: a **Task** is just an async function that a `context` as its first parameter, followed by any input arguments it needs." ```typescript interface AppContext { greeting: string; } // This is a Task - just a regular async function! async function greetTask(context: AppContext, name: string): Promise<string> { return `${context.greeting}, ${name}!`; } // You could call it directly (but you won't need to) const message = await greetTask({ greeting: 'Hello' }, 'World'); ``` This is the fundamental building block. It's just a function, making it easy to understand and test in isolation. #### Step 2: `defineTask` Makes Context Implicit Writing `context` as the first parameter every time is tedious. `defineTask` is a simple helper that makes the context implicit and accessible via a `getContext()` function. **The Simple Way (No Setup Needed):** ```typescript import { defineTask, getContext, run } from '@doeixd/effectively'; // No context creation needed! Smart functions use a global default. const greet = defineTask(async (name: string) => { const context = getContext(); // Gets global default context return `Hello, ${name}!`; }); await run(greet, 'World'); // Just works! ``` **The Custom Way (When You Need Specific Dependencies):** ```typescript import { createContext, type Scope } from '@doeixd/effectively'; // Define your context interface first interface AppContext { scope: Scope; // Required by the library but better solution exists greeting: string; } const { defineTask, getContext } = createContext<AppContext>({ greeting: 'Hello' }); // After: defineTask makes context implicit const greet = defineTask(async (name: string) => { const { greeting } = getContext(); // Typesafe Context is now available via getContext() return `${greeting}, ${name}!`; }); await run(greet, "World"); ``` **The Smart Way (Best of Both Worlds):** ```typescript import { defineTask, getContext, run, type BaseContext } from '@doeixd/effectively'; interface CustomContext extends BaseContext { // The BaseContext type automatically includes the necessary scope property, solving this boilerplate for you. greeting?: string; } // This task works in ANY context - it adapts automatically! const smartGreet = defineTask(async (name: string) => { const context = getContext<CustomContext>(); // Smart: uses current context or global default const greeting = context.greeting || 'Hello'; return `${greeting}, ${name}!`; }); // Works with global context await run(smartGreet, 'World'); // Also works within custom contexts const { run: customRun } = createContext({ greeting: 'Hi' }); await customRun(smartGreet, 'World'); // Uses custom greeting ``` That's it! **`defineTask` doesn't do anything magical**β€”it just wraps your function to handle the context parameter for you, making your code cleaner. The smart context system means you can start simple and add complexity only when needed. #### Step 3: Workflows Chain Tasks Together Once you have tasks, you can chain them together using `createWorkflow`. The output of one task becomes the input to the next. ```typescript interface User { id: string; name: string; } interface EnrichedUser extends User { profile: { title: string }; } const fetchUser = defineTask(async (userId: string) => { const { api } = getContext(); return api.getUser(userId); }); const enrichUser = defineTask(async (user: User) => { const { api } = getContext(); const profile = await api.getProfile(user.id); return { ...user, profile }; }); // A plain async function can also be a step in a workflow. // Effectively will automatically "lift" it into a Task for you. async function formatUser(enrichedUser: EnrichedUser): Promise<string> { return `${enrichedUser.name} (${enrichedUser.profile.title})`; } // Chain them together into a workflow const getUserDisplay = createWorkflow( fetchUser, enrichUser, formatUser ); // This creates a new, single Task that runs all three in sequence. // πŸ’₯ this will fail because the context is missing. continue to a next step. await run(getUserDisplay, "123"); ``` #### Step 4: `run` Provides the Context Tasks need a context to execute. The `run` function, created by `createContext`, provides it. ```typescript import { createContext, createWorkflow, type BaseContext } from '@doeixd/effectively'; interface User { id: string; name: string; } interface EnrichedUser extends User { profile: { title: string }; } interface ApiClient { getUser: (userId: string) => Promise<User>; getProfile: (userId: string) => Promise<{ title: string }>; } // dummy implementation of the API client const myApiClient: ApiClient = { getUser: async (userId: string) => ({ id: userId, name: "John Doe" }), getProfile: async (userId: string) => ({ title: "Developer" }), }; interface AppContext { greeting: string; api: ApiClient; } // Create your app's context with default dependencies const { run, defineTask, getContext } = createContext<AppContext>({ greeting: 'Hello', api: myApiClient }); // define task in AppContext scope const greet = defineTask(async (name: string) => { const { greeting } = getContext(); return `${greeting}, ${name}!`; }); // Run a single task const message = await run(greet, 'World'); // We used these in Step 3 chapter for workflow but defined task in context const fetchUser = defineTask(async (userId: string) => { const { api } = getContext(); // type-safe method calling return api.getUser(userId); }); const enrichUser = defineTask(async (user: User) => { const { api } = getContext(); // type-safe method calling const profile = await api.getProfile(user.id); return { ...user, profile }; }); // "lift" it into a Task automatically in the workflow async function formatUser(enrichedUser: EnrichedUser): Promise<string> { return `${enrichedUser.name} (${enrichedUser.profile.title})`; } const getUserDisplay = createWorkflow(fetchUser, enrichUser, formatUser); // Run a workflow (which is also just a Task!) const display = await run(getUserDisplay, 'user-123'); ``` ### Step 5: Effect Handlers Here's where Effectively gets powerful: you can build **algebraic(ish) effect handlers** on top of the context system. These allow you to define abstract effects (like "get user input" or "log a message") and provide different implementations in different contexts. At its most fundamental level, you can manage this with the raw context system: ```typescript import { defineTask, getContext, run, type BaseContext } from '@doeixd/effectively'; interface AppContext extends BaseContext {} // Define an effect interface interface Effects { input: (prompt: string) => Promise<string>; log: (message: string) => Promise<void>; } // A task that uses effects abstractly by pulling them from the context const greetUser = defineTask(async () => { const { input, log } = getContext<AppContext & Effects>(); const name = await input("What's your name?"); const greeting = `Hello, ${name}!`; await log(greeting); return greeting; }); // Provide different implementations for different contexts const webEffects: Effects = { input: (prompt) => Promise.resolve(window.prompt(prompt) || ''), log: (msg) => { console.log(msg); return Promise.resolve(); } }; const testEffects: Effects = { input: (prompt) => Promise.resolve('Test User'), log: (msg) => Promise.resolve() // Silent in tests }; // Use with different effect implementations await run(greetUser, undefined, { overrides: webEffects }); // Web version await run(greetUser, undefined, { overrides: testEffects }); // Test version ``` This raw approach works, but for convenience, Effectively provides a **dedicated effects system** that adds better structure, type safety, and error handling. #### A Dedicated System for Effects This system lets you formally define effects as callable placeholders. **1. Define the "what"** using `defineEffect`. This creates a function that, when called, will look for its implementation in the context. ```typescript import { defineEffect, withHandlers, defineTask, run } from '@doeixd/effectively'; // Define effects - the "what" without the "how" const log = defineEffect<(message: string) => void>('log'); const input = defineEffect<(prompt: string) => string>('input'); // A task that uses the effects directly const greetUser = defineTask(async () => { const name = await input("What's your name?"); const greeting = `Hello, ${name}!`; await log(greeting); return greeting; }); ``` **2. Provide the "how"** using `withHandlers`. This helper correctly places your handler implementations into the context where the effects can find them. ```typescript // Provide different implementations for different contexts const webHandlers = { input: (prompt: string) => window.prompt(prompt) || '', log: (msg: string) => console.log(msg) }; const testHandlers = { input: (prompt: string) => 'Test User', log: (msg: string) => {} // Silent in tests }; // Use with different effect implementations await run(greetUser, undefined, withHandlers(webHandlers)); // Web version await run(greetUser, undefined, withHandlers(testHandlers)); // Test version ``` For applications with multiple effects, you can manage them with the `defineEffects` and `createHandlers` helpers. This is where you can also add a layer of **opt-in type safety**. ```typescript import crypto from "node:crypto"; import fs from "node:fs"; import { defineEffects, createHandlers, withHandlers, defineTask, run } from '@doeixd/effectively'; // 1. Define all your effects at once from a single type contract type AppEffects = { log: (message: string) => void; getUniqueId: () => string; readFile: (path: string) => string, } const effects = defineEffects<AppEffects>(); // 2. Create a handlers object const handlers = createHandlers({ log: console.log, getUniqueId: () => crypto.randomUUID(), readFile: (path) => fs.readFileSync(path, 'utf8'), }); // build a task to run const myTask = defineTask(async (input) => { const id = await effects.getUniqueId(); await effects.log(`Generated ID: ${id}`); const content = await effects.readFile(input); await effects.log(`File content: ${content}`); }); const input = 'src/toc.txt'; // 3. To ensure safety, you can provide the contract type to `withHandlers`. // This lets TypeScript validate that your handlers match the contract. await run(myTask, input, withHandlers<AppEffects>(handlers)); ``` #### The Challenge: Ensuring Safety Across Your App While adding `<AppEffects>` to `withHandlers` is a great way to add safety, it's a manual step. You have to remember to do it for every `run` call. In a large application, it's easy to forget, re-introducing the risk that your effect definitions and handler implementations could drift out of sync. A typo or a missing handler might not be caught by the compiler. #### The Recommended Solution: createEffectSuite To solve this and provide permanent, end-to-end type safety, the library includes the **`createEffectSuite`** factory. It creates a single, unified toolkit where your effects and handlers are **always** linked to the same contract. ```typescript import { createEffectSuite, defineTask, run } from '@doeixd/effectively'; // 1. Define the contract, just like before. type AppEffects = { log: (message: string) => void; getUniqueId: () => string; }; // 2. Create the suite. This is the key step. const { effects, createHandlers, withHandlers } = createEffectSuite<AppEffects>(); // 3. The task definition is identical. const myTask = defineTask(async () => { const id = await effects.getUniqueId(); await effects.log(`Task run with ID: ${id}`); }); // 4. Create handlers using the suite's `createHandlers`. // βœ… It's impossible to make a mistake here. If a handler is missing // or has a typo, you will get a COMPILE-TIME ERROR. const myHandlers = createHandlers({ log: console.log, getUniqueId: () => 'test-id-123', }); // 5. Run the task using the suite's `withHandlers`. // This is also validated against the contract automatically. await run(myTask, undefined, withHandlers(myHandlers)); ``` This simple, layered approachβ€”from plain async functions to composable workflows with a robust and automatically safe effects systemβ€”is the core of Effectively. ### Step 6: 🀝 Unifying Custom Context and Effects So far, we've treated custom contexts (for dependencies like an `api` client) and effect handlers (for abstracting side effects like `log`) as separate tools. But what happens when a single task needs access to **both**? This is where the true power of composition shines, but it also reveals a common TypeScript challenge that `Effectively` now elegantly solves. #### The Challenge: A Tale of Two Systems Let's try to use the `run` function from our custom `AppContext` (from Step 4) with the `withHandlers` from our `AppEffects` suite (from Step 5). ```typescript // From Step 4: We have a context system for AppContext interface AppContext extends BaseContext { api: ApiClient; } const { run: appRun } = createContext<AppContext>({ api: myApiClient }); // From Step 5: We have a suite of effects and handlers type AppEffects = { log: (message: string) => void; }; const { effects, withHandlers, createHandlers } = createEffectSuite<AppEffects>(); const myHandlers = createHandlers({ log: console.log }); const myTask = defineTask(async () => { // This task wants to use BOTH the custom context and the effects const { api } = getContext<AppContext>(); await effects.log(`Using the API...`); // ... }); // πŸ’₯ This will cause a TypeScript error! await appRun(myTask, undefined, withHandlers(myHandlers)); ``` The error occurs because our `appRun` function only knows about the `AppContext` interface (`{ api }`). The `withHandlers` helper tries to add effect handler implementations to the context, but the `AppContext` type doesn't know anything about them. TypeScript, doing its job, correctly tells us these two worlds are disconnected. #### Solution A (The Manual Way): `ContextWithHandlers` Helper Type The first solution is to explicitly teach your context about the effects it will need to handle. Instead of requiring you to know the library's internal details, you can now use the `ContextWithHandlers<TContext, THandlers>` utility type. **Before (Verbose and requires internal knowledge):** ```typescript import { HANDLERS_KEY, type BaseContext } from '@doeixd/effectively'; interface AppContext extends BaseContext { api: ApiClient; // Manually adding the internal property [HANDLERS_KEY]?: Record<string, any>; } const { run } = createContext<AppContext>({ api: myApiClient }); ``` **After (Clean and declarative with the helper):** ```typescript import { createContext, createEffectSuite, type BaseContext, type ContextWithHandlers // <-- Import the helper type } from '@doeixd/effectively'; // Define your context and effects as before interface AppContext extends BaseContext { api: ApiClient; } type AppEffects = { log: (message: string) => void; }; // βœ… Use the helper to create the combined type. It's much cleaner. type AppServiceContext = ContextWithHandlers<AppContext, AppEffects>; // Create your tools using the combined type const { run } = createContext<AppServiceContext>({ api: myApiClient, }); const { effects, withHandlers, createHandlers } = createEffectSuite<AppEffects>(); /* ... task definition ... */ const myHandlers = createHandlers({ log: console.log }); // βœ… No more errors! await run(myTask, undefined, withHandlers(myHandlers)); ``` This is a clean and explicit way to compose the two systems. But we can do even better. #### Solution B (The Recommended Way): `createEffectiveSystem` For the most seamless experience, the library now includes a "batteries-included" factory function called `createEffectiveSystem`. It combines your custom context and your effects contract into a single, unified toolkit from the very beginning. This is now the **recommended entry point for most applications.** ```typescript import { createEffectiveSystem, type BaseContext } from '@doeixd/effectively'; // 1. Define your context and effects types as usual. interface AppContext extends BaseContext { api: ApiClient; } type AppEffects = { log: (message: string) => void; }; // 2. Use the factory to create a fully integrated system. // Pass both types as generics and provide the default context data. const { run, getContext, defineTask, effects, createHandlers, withHandlers } = createEffectiveSystem<AppContext, AppEffects>({ context: { api: myApiClient } }); // 3. Define tasks and handlers using the tools from the system. // Everything is automatically and correctly typed. const myTask = defineTask(async () => { const { api } = getContext(); // βœ… Correctly infers `api` property await effects.log('This just works!'); }); const myHandlers = createHandlers({ // βœ… This is fully aware of AppEffects. A typo here is a compile-error. log: (message) => console.log(`[SYSTEM LOG] ${message}`), }); // 4. Run the task. It works perfectly with zero manual type-juggling. await run(myTask, undefined, withHandlers(myHandlers)); ``` With `createEffectiveSystem`, you get the power of both a custom context and a type-safe effects system with none of the friction. This simple, layered approach is the core of building robust, maintainable applications with `Effectively`. <br /> ## πŸ’‘ Core Concepts Now that you have the intuition, let's formalize the key concepts: ### 1. Tasks: Your Building Blocks A **Task** is the atomic unit of work. As you've seen, it's an async function made composable by `defineTask`. This makes dependencies explicit and your code testable. ### 2. Workflows: Composition Made Simple A **Workflow** chains Tasks together. `createWorkflow` creates a new Task where the output of one becomes the input of the next. **Visual Flow:** `CardInput β†’ [validateCard] β†’ ValidCard β†’ [chargeCard] β†’ ChargeResult β†’ [sendReceipt] β†’ Receipt` ### 3. Context: Smart Dependency Injection **Context** provides your dependencies (like API clients, loggers, or config) without prop drilling or global state. Effectively now features a **smart context system** with three variants: - **Smart functions** (`getContext`, `defineTask`, `run`): Automatically use the current context if available, otherwise fall back to a global default context - **Local-only functions** (`getContextLocal`, `defineTaskLocal`, `runLocal`): Only work within an active context, throwing errors if none exists - **Global-only functions** (`getContextGlobal`, `defineTaskGlobal`, `runGlobal`): Always use the global default context, ignoring any current context This allows you to start simple (no context creation needed) and progressively enhance your application with custom contexts as needed. ### 4. Effect Handlers and Brackets **Effect Handlers** enable algebraic effects through the context system, allowing you to write code that's abstract over side effects. **[Brackets](docs/bracket-resource-management.md)** provide guaranteed resource cleanup using the acquire-use-release pattern, ensuring resources are properly disposed of even when errors occur. [See more](docs/bracket-resource-management.md) ### 5. Scope and Cancellation **Scope** manages the lifecycle of operations and enables cancellation. When a scope is cancelled, all tasks running within that scope receive cancellation signals, allowing for graceful shutdown and resource cleanup. This prevents resource leaks and allows for responsive user interfaces. <br /> ## πŸ›‘οΈ Error Handling: A Dual Strategy Effectively promotes two complementary approaches to error handling. For a comprehensive guide on error handling strategies, see the [Error Handling Guide](docs/error-handling.md). ### 1. Domain Errors: Use `Result<T, E>` For expected failures that are part of your business logic (e.g., validation errors), use the `Result` type from `neverthrow`. This forces you to handle potential failures at compile time. ```typescript import { Result, ok, err } from 'neverthrow'; // Note: All context types must include scope: Scope interface AppContext { scope: Scope; // ... your other context properties } const { defineTask } = createContext<AppContext>({ /* ... */ }); const validateAge = defineTask(async (age: number): Promise<Result<number, ValidationError>> => { if (age < 0) return err(new ValidationError('Age cannot be negative')); return ok(age); }); // Force handling at compile time const workflow = createWorkflow( validateAge, (result) => result.match({ ok: (age) => `Valid age: ${age}`, err: (error) => `Invalid: ${error.message}` }) ); ``` ### 2. System Panics: Use `withErrorBoundary` For unexpected failures that represent system-level problems (e.g., network down, database connection lost), use `withErrorBoundary`. This allows you to catch and handle specific error types at runtime. ```typescript const protectedWorkflow = withErrorBoundary( riskyDatabaseOperation, createErrorHandler( [NetworkError, async (err) => { await logToSentry(err); return cachedFallbackData; }], [DatabaseError, async (err) => { await notifyOps(err); throw new ServiceUnavailableError(); }] ) ); ``` This dual approach ensures: - **Compile-time safety** for predictable errors - **Runtime resilience** for unexpected failures - **Clear separation** between business logic and infrastructure concerns ### Traditional `try/catch` & `Promise.catch()` (Seamless Integration) **Effectively is designed to work beautifully with the error handling mechanisms you already know.** You don't need to abandon `try/catch` or `Promise.catch()`. In fact, they are often the simplest way to handle errors within the logic of a single task or when integrating with third-party libraries. Since Effectively tasks are fundamentally `async` functions returning Promises, standard JavaScript error handling just works. ```typescript // import { defineTask, run, createContext, type BaseContext } from '@doeixd/effectively'; // Assuming imports // const { defineTask: appDefineTask, run: appRun } = createContext<BaseContext>({}); // --- Using try/catch within a Task's logic --- const taskWithInternalTryCatch = appDefineTask(async (path: string) => { let fileContent: string; try { // Simulate an operation that might throw if (path === 'nonexistent.txt') { throw new Error(`File not found: ${path}`); } fileContent = `Content of ${path}`; // Replace with actual fs.readFile console.log(`Successfully read ${path}`); } catch (error: any) { console.error(`[Task Logic] Failed to read file '${path}': ${error.message}`); // You can handle it here, re-throw, or return a default/error indicator fileContent = `Error reading ${path}: ${error.message}`; // Recover with an error message // Or: throw new CustomError("Failed to process file", { cause: error }); } return `Processed: ${fileContent}`; }); // --- Using .catch() when running a Task or Workflow --- const potentiallyFailingTask = appDefineTask(async (shouldFail: boolean) => { if (shouldFail) { throw new Error("Simulated failure in task"); } return "Task succeeded!"; }); // You can use .catch() directly on the Promise returned by run() appRun(potentiallyFailingTask, true) .then(result => console.log("Run succeeded:", result)) .catch(error => console.error("Run failed with .catch():", error.message)); // Or within an async function: async function executeAndHandle() { try { const result = await appRun(potentiallyFailingTask, false); console.log("Traditional try/catch: Task result:", result); await appRun(potentiallyFailingTask, true); // This will throw } catch (error: any) { console.error("Traditional try/catch: Caught error from run():", error.message); } } executeAndHandle(); ``` <br /> ## πŸš€ Features ### Guaranteed Resource Cleanup Never leak resources again with the `bracket` pattern, which ensures a `release` function is always called, even if the `use` function throws an error. For detailed resource management patterns, see the [Bracket Resource Management Guide](docs/bracket-resource-management.md). ```typescript const processFile = withResource({ acquire: () => openFile('data.csv'), use: (file) => parseAndProcess(file), release: (file) => file.close() // Always runs! }); ``` ### Built-in Resilience Patterns Add production-grade resilience to any task with simple wrappers. ```typescript // Automatic retries with exponential backoff const resilientFetch = withRetry(fetchData, { attempts: 3, delayMs: 1000, backoff: 'exponential' }); // Timeouts to prevent long-running operations const quickFetch = withTimeout(fetchData, 5000); // Circuit breakers to prevent cascading failures const protectedCall = withCircuitBreaker(externalApi, { failureThreshold: 5, resetTimeout: 60000 }); ``` ### Structured Concurrency Go beyond `Promise.all` with named results, partial failure handling, and efficient data processing. For advanced concurrency patterns and native scheduler integration, see the [Parallel Execution Guide](docs/parallel.md). ```typescript // Parallel execution with named results const results = await run( createWorkflow( fromValue(userData), forkJoin({ profile: fetchProfile, orders: fetchOrders, preferences: fetchPreferences }) ) ); // results: { profile: Profile, orders: Order[], preferences: Prefs } // Map-reduce for parallel data processing const total = await mapReduce( orderIds, (id) => fetchOrderAmount(id), // Runs in parallel (acc, amount) => acc + amount, // Sequential reduction 0 ); ``` ### Memory-Safe Long-Running Workflows Effectively prevents memory accumulation in long-running workflows through stateless execution and automatic cleanup. ```typescript // Process millions of items without memory leaks const processLargeDataset = mapReduce( millionItems, processItem, // Parallel processing (acc, result) => acc + result.value, // Sequential aggregation 0, { concurrency: 10 } // Bounded concurrency prevents memory spikes ); // Batch processing with automatic context cleanup const processBatch = defineTask(async (items: Item[]) => { const batchSize = 1000; for (let i = 0; i < items.length; i += batchSize) { const batch = items.slice(i, i + batchSize); await processItems(batch); // Each batch's context is cleaned up automatically } }); ``` **Key Memory Management Features:** - **Stateless Execution:** Contexts are created fresh for each workflow and disposed automatically - **Scope-Based Cleanup:** AbortControllers and event listeners are cleaned up in finally blocks - **No Accumulation:** Tasks don't retain state between executions, preventing memory leaks - **Resource Bracketing:** Guaranteed cleanup of connections, files, and other resources <br /> ## πŸ€” Comparisons & Where It Fits Effectively offers a powerful and pragmatic approach to building resilient TypeScript applications. Understanding how it compares to other tools and paradigms can help you decide if it's the right fit for your project. Our core philosophy is to **enhance `async/await` with structured patterns and opt-in algebraic effects**, rather than requiring a full paradigm shift. For a deeper dive into the motivations, see [Why Effectively?](docs/why-effectively.md). ### 1. Plain `async/await` & Native Promises **Use When:** * You're writing simple scripts with minimal asynchronous logic. * Dependency management is straightforward (e.g., direct imports, few shared services). * Error handling, retries, and resource management are trivial or not critical. * You're building a very small library where zero external dependencies are paramount. **How Effectively Differs:** Plain `async/await` is the foundation. Effectively builds upon it by providing: * **Structured Dependency Injection:** The `Context` system eliminates prop-drilling and global singletons. * **Composable Units:** `Task` and `Workflow` primitives make complex async flows manageable. * **Built-in Resilience:** `withRetry`, `withTimeout`, `withCircuitBreaker` add production-readiness easily. * **Guaranteed Resource Cleanup:** The `bracket` pattern prevents leaks. * **Standardized Error Handling:** A clear strategy for domain vs. system errors. * **Opt-in Algebraic Effects:** For abstracting side effects when needed, without forcing it everywhere. Effectively aims to be the natural next step when your `async/await` code starts to become complex and brittle. ### 2. Full Functional Effect Systems (e.g., Effect-TS, fp-ts) These libraries provide powerful, all-encompassing ecosystems for purely functional programming, often with their own runtimes (like fibers) and a deep emphasis on type-driven development and total effect tracking. **Use When:** * Your team is fully committed to and proficient in pure functional programming. * You require the advanced capabilities of a fiber-based runtime (e.g., fine-grained concurrency control, true delimited continuations). * You want compile-time guarantees for *all* side effects, enforced by the type system across the entire application. * Learning a new, comprehensive programming model is acceptable for the benefits gained. **How Effectively Differs:** * **Lower Learning Curve:** Builds directly on `async/await` and familiar TypeScript patterns. The algebraic effects system in Effectively is opt-in and designed to be more approachable. * **Seamless Integration:** Works effortlessly with existing Promise-based libraries and codebases. No need to wrap everything in a special `Effect` type. * **Pragmatism over Purity:** While encouraging good patterns, Effectively doesn't enforce strict purity for all operations. Its effects system is a tool for better testability and abstraction where it provides the most value. * **Familiar Debugging:** Stack traces and debugging feel closer to standard TypeScript `async/await`. Effectively offers many benefits of structured programming and effect management without the steep learning curve or the "all-or-nothing" commitment of full FP effect systems. ### 3. Generator-Based Algebraic Effect Libraries (e.g., Tinyeffect) Libraries like Tinyeffect use generator functions (`function*`) and `yield*` as the primary mechanism to define and handle all side effects (DI, errors, async operations) in a unified, type-safe manner. **Use When:** * You want a **singular, unified model** for all side effects, where everything is an explicitly declared and handled effect. * Your team is comfortable with generator-based control flow as the dominant pattern. * The strong compile-time guarantee that *all* declared effects are handled before runtime is paramount. * The "purity" of knowing exactly what effects a piece of code can perform (from its type signature) is a primary design goal. **How Effectively Differs:** * **`async/await` as the Core:** Effectively's `Tasks` are standard `async` functions. This provides a more conventional programming model for many developers and easier integration with the broader JavaScript/TypeScript ecosystem. Generators are opt-in for `doTask` notation. * **Separate Concerns, Synergistic Solutions:** * **Dependency Injection:** Effectively has a robust, dedicated `Context` system that is intuitive and powerful on its own, independent of the effects system. * **Error Handling:** Provides a pragmatic dual strategy (`Result` for domain errors, `withErrorBoundary` for panics) that works well with standard `async/await` throwing behavior. * **Algebraic Effects:** Effectively's `defineEffect` and `withHandlers` system allows abstracting specific side effects where beneficial (e.g., for testability or swappable implementations), but doesn't *require* DI or basic async operations to be effects. * **Progressive Enhancement:** You can use Effectively's `Context`, `Workflows`, and resilience patterns without immediately diving into its algebraic effects system. Adopt features as your application's complexity grows. * **Built-in Utilities Beyond Effects:** Patterns like `bracket`, `withRetry`, and `forkJoin` are first-class utilities, not just patterns to be implemented via custom effect handlers. The table below summarizes key differences with more direct competitors in the "effects" space: | Aspect | **Effectively** | **Effect-TS** | **Tinyeffect** | | :------------------------- | :------------------------------------------------------------------------------ | :--------------------------------------------- | :----------------------------------------------------- | | **Primary Paradigm** | Enhances `async/await` with patterns & opt-in algebraic effects via `Context` | Pure Functional Programming, Fiber-based runtime | Algebraic effects via Generators | | **Core Abstraction** | `Task` (async function), `Context`, `Workflow` | `Effect` data type | `Effected` program (generator) | | **Learning Curve** | Low to Medium (builds on existing knowledge, effects are opt-in) | High (new programming model & ecosystem) | Medium (generator syntax, effect handling model) | | **Integration** | Seamless with existing Promise-based code | Requires wrapping code in `Effect` runtime | Requires generator functions & `yield*`; `effectify` for Promises | | **DI Approach** | Explicit `Context` system, `getContext()`, overrides | Typically via `Context` or `Layer` (Effect's DI) | `dependency` effect, handled by `provide` | | **Error Handling** | `Result<T,E>` for domain, `withErrorBoundary` for panics; Tasks can throw | All errors are values within `Effect` type | Errors are `error` effects, handled by `catch` | | **Effect System Scope** | Opt-in for specific side effects (e.g., I/O, external services) | All side effects are managed by `Effect` | All side effects are managed by `effected` programs | | **Best For** | Teams wanting structured `async/await`, pragmatic DI, resilience, and testable side effects with a gradual learning curve. | Teams committed to pure FP, seeking maximum purity, type safety, and powerful concurrency abstractions. | Teams wanting a unified, type-safe model for *all* side effects using generators. | ### 4. Reactive Programming (e.g., RxJS) **Use When:** * Your application is primarily event-driven and involves managing complex streams of asynchronous events over time (e.g., UI interactions, WebSockets, real-time data feeds). * You need powerful stream manipulation operators like `debounce`, `throttle`, `buffer`, `mergeMap`, etc. **How Effectively Differs:** Effectively is designed for managing **workflows** – sequences of operations that typically have a defined start and end, often involving fetching data, processing it, and producing a result or side effect. RxJS excels at managing ongoing **streams** of events. While there can be overlap, their primary use cases are distinct. You might even use both in a larger application (e.g., RxJS for UI events, triggering an Effectively workflow). ### 5. Synchronous Code **Use When:** * Your code doesn't involve I/O, timers, or other asynchronous operations. * You are performing pure computations on in-memory data. **How Effectively Differs:** Effectively is specifically for asynchronous code. Introducing its patterns for purely synchronous logic would be unnecessary overhead. <br /> ## 🧠 Advanced Concepts ### Non-Linear Control Flow: Backtracking and Effects Effectively provides powerful non-linear control flow through **backtracking**. Throwing a `BacktrackSignal` allows a workflow to jump back to a previously executed task. This is ideal for retries, polling, and state machines. ```typescript const retryableTask = defineTask(async (attempt: number) => { const result = await riskyOperation(); if (result.needsRetry && attempt < 3) { // Jump back to this same task with an incremented attempt number throw new BacktrackSignal(retryableTask, attempt + 1); } return result; }); ``` **Important:** Tasks must be created with `defineTask` to enable backtracking, as this assigns a unique ID used by the runtime. #### No Trampolining, No Rollback Unlike some effect systems, Effectively **does not use trampolining** and **does not provide automatic rollback** of side effects. This design choice has important implications: - **Performance**: Direct function calls without trampolines mean better performance and stack traces - **Side Effects**: When backtracking occurs, any side effects that have already happened remain in place - **Responsibility**: You are responsible for designing idempotent operations or manually cleaning up state when retrying ```typescript const taskWithSideEffects = defineTask(async (attempt: number) => { // This side effect will happen every time we backtrack await logAttempt(attempt); await incrementCounter(); // This won't be rolled back! const result = await riskyOperation(); if (result.needsRetry && attempt < 3) { // The log and counter increment above have already happened // and won't be undone when we backtrack throw new BacktrackSignal(taskWithSideEffects, attempt + 1); } return result; }); ``` This makes the control flow easy to reason about - effects happen when they execute, period. For operations that need atomicity, use patterns like the bracket pattern or explicit transaction management. ### Concurrency: Leveraging the Platform Effectively embraces the browser and Node.js's native concurrency primitives rather than reimplementing them. This means it uses `scheduler.postTask` when available for cooperative multitasking, and you can leverage `SharedArrayBuffer` and `Atomics` when using the Web Worker integration for true parallelism. ### Do-Notation for Monadic Composition Effectively supports Haskell-style do-notation using generator functions for elegant monadic composition. The `doTask` function allows you to chain operations using `yield` syntax: ```typescript const userWorkflow = doTask(function* (userId: string) { const user = yield fetchUser(userId); const profile = yield fetchProfile(user.id); const permissions = yield fetchPermissions(user.role); // Use pure() to lift plain values into the monadic context return yield pure({ user, profile, permissions }); }); ``` #### Generator Composition with `yield*` You can compose and reuse generator functions using `yield*` for powerful modular patterns: ```typescript // Reusable sub-generators function* fetchUserCore(userId: string) { const user = yield getUser(userId); const profile = yield getProfile(user.id); return { user, profile }; } // Compose them into larger workflows const completeUserData = doTask(function* (userId: string) { const coreData = yield* fetchUserCore(userId); // Delegate to sub-generator const settings = yield getSettings(userId); // Direct yield return { ...coreData, settings }; }); ``` This provides a clean alternative to deeply nested `.then()` chains or complex workflow compositions, with support for both direct value unwrapping (`yield`) and generator composition (`yield*`). <br /> ## πŸ“š Guides & Deeper Dives ### Our Philosophy & FAQ Curious about the "why" behind our design decisions? We've written a detailed article explaining our core philosophy of pragmatism, why we choose to enhance `async/await` rather than replace it, and how `Effectively` compares to powerful functional ecosystems like Effect-TS. If you've ever wondered about our take on runtimes, fibers, and typed errors, this is the definitive guide. [Why Effectively](docs/why-effectively.md) &nbsp;β€’&nbsp; [Philosophy & FAQ](docs/faq-our-philosophy.md) &nbsp;β€’&nbsp; [Effectively vs Effect.ts](docs/effect-ts-vs-effectively.md) ### Smart Context System For a comprehensive guide to the smart context system with smart, local-only, and global-only functions, see the [Context System Guide](docs/context-system.md). This covers when to use each variant, migration strategies, and best practices for different use cases. ### Effect Handlers For detailed information on building testable, modular code with algebraic effect handlers, see the [Effect Handlers Guide](docs/effect-handlers.md). This covers effect definition, handler creation, testing patterns, and advanced composition techniques. ### Do Notation with Generator Syntax For more detailed information on monadic composition using generators, see the [Do Notation Guide](docs/do-notation.md). This covers advanced patterns, error handling within do blocks, and performance considerations. ### Performance & Debugging Pass a logger to the `run` function to get detailed insight into your workflow's execution, including task timing and success/failure states. For large datasets, use `stream()` or `mapReduce()` with a `concurrency` limit to process data efficiently without overwhelming the system. For advanced concurrency control and native scheduler integration, see the [Parallel Execution Guide](docs/parallel.md). ### Setting Up Web Workers Offload CPU-intensive work to a separate thread without the usual boilerplate. **1. Worker File (`worker.ts`)** ```typescript import { createWorkerHandler, defineTask } from '@doeixd/effectively/worker'; const heavyCalculation = defineTask(async (data: number[]) => { // ... intensive processing return processedData; }); createWorkerHandler({ heavyCalculation }); ``` **2. Main Thread (`main.ts`)** ```typescript import { runOnWorker } from '@doeixd/effectively'; const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }); const calculateOnWorker = runOnWorker(worker, 'heavyCalculation'); const result = await run(calculateOnWorker, myDataArray); ``` ### Creating Custom Enhancers An enhancer is a function that takes a Task and returns a new Task with added behavior. This is a powerful way to create reusable patterns. ```typescript // An enhancer that adds caching to any task const withCache = <C extends { cache: Cache }, V, R>( task: Task<C, V, R>, options: { ttl: number } ): Task<C, V, R> => { return defineTask(async (value: V) => { const { cache } = getContext<C>(); const key = JSON.stringify(value); const cached = await cache.get(key); if (cached) return cached as R; const result = await task(getContext(), value); await cache.set(key, result, options.ttl); return result; }); }; ``` <br /> ## πŸ“‹ Best Practices - **Keep Tasks Focused:** Each task should have a single responsibility. Compose them in workflows rather than creating monolithic tasks. - **Use `Result` for Domain Errors:** Use `neverthrow`'s `Result` type for predictable errors (e.g., validation), forcing compile-time checks. - **Use `withErrorBoundary` for System Errors:** Reserve throwing and error boundaries for unexpected system failures (e.g., network loss). - **Define Clear Context Interfaces:** Keep your `AppContext` clean and well-defined. Pass request-specific data through the workflow, not in the context. - **Always Use `bracket` for Resources:** Guarantee cleanup for files, database connections, or other resources that need explicit closing. <br /> ## ⚠️ Common Pitfalls & Solutions ### Context-Related Issues - **`ContextNotFoundError` with smart functions:** You called `getContext()` outside of any execution context and there's no global default. The smart functions will automatically use global context as fallback, but if you're using `getContextLocal()`, it requires an active context. - **Unexpected context behavior:** If you're getting a different context than expected, check which function variant you're using: - `getContext()` (smart) - uses current context or global fallback - `getContextLocal()` - requires current context, throws if none - `getContextGlobal()` - always uses global, ignores current context - **Type safety issues:** Use generic versions for type safety: `getContext<MyContext>()` instead of `getContext()` when you know the context type. - **Context not inheriting properties:** Remember that `defineTask()` is smart and will inherit the context it's defined in. Use `defineTaskGlobal()` if you need consistent global context behavior. ### General Issues - **Enhancer Not Working:** Enhancers (`withRetry`, `withTimeout`, etc.) return a *new* task. You must use the returned value. `const retried = withRetry(myTask);` not `withRetry(myTask);`. - **Backtracking Not Working:** The target task was not created with `defineTask`. The runtime needs the `__task_id` assigned by `defineTask` to find it. - **Workflow Stops Midway:** An unhandled error was likely thrown. Debug by running with `{ throw: false }` to inspect the returned `Result` object: `const result = await run(workflow, input, { throw: false });`. ### Choosing the Right Context Function Use this guide to choose the appropriate context function: | Use Case | Function | Reason | |----------|----------|---------| | General usage, want convenience | `getContext()` | Smart fallback behavior | | Want type safety | `getContext<MyContext>()` | Explicit typing | | Must ensure you're in a specific context | `getContextLocal<MyContext>()` | Throws if wrong context | | Always want global context | `getContextGlobal()` | Predictable behavior | | Building a library | `getContextLocal()` or `getContext<C>()` | Explicit context requirements | ### Async Context & Environment Issues Effectively uses [unctx](https://github.com/unjs/unctx) under the hood for context management, which can present challenges in certain environments: #### **"Context is not available" Errors** **Problem:** Tasks throw "Context is not available" when run in certain environments (tests, browsers, edge functions). **Causes & Solutions:** 1. **AsyncLocalStorage Unavailable:** - **Browser environments:** `AsyncLocalStorage` is Node.js-specific and not available in browsers - **Edge environments:** Some edge runtimes don't support `node:async_hooks` - **Solution:** Effectively automatically falls back to sync context when `AsyncLocalStorage` fails, but you may need to wrap async functions 2. **Context Lost After Await:** ```typescript // ❌ This will lose context after the await const task = defineTask(async (input) => { const context = getContext(); // Works await someAsyncOperation(); const context2 = getContext(); // ❌ May throw "Context is not available" }); ``` **Solutions:** - **Cache context early:** Store context in a variable before async operations ```typescript const task = defineTask(async (input) => { const context = getContext(); // Cache it await someAsyncOperation(); // Use cached context instead of calling getContext() again }); ``` - **Use unctx transform (advanced):** Install the unctx Vite/Webpack plugin to automatically preserve context #### **Build Tool Integration (Advanced)** For applications that heavily use async operations and need context preserved across await boundaries, consider using the unctx transform: **Vite Integration:** ```typescript // vite.config.ts import { unctxPlugin } from 'u