Skip to main content

TS Ep 63: Creating and Running Effects

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 63: This Article
The outside world is impure, unpredictable, and entirely built on Promises and Callbacks. To bring order to the chaos, we must wrap the world in pure, lazy Effect containers.

TL;DR (Quick Summary)
#

  • Pure Values: Use Effect.succeed(value) and Effect.fail(error) for hardcoded, static values.
  • Synchronous Logic: Use Effect.sync(() => doMath()) for safe synchronous execution.
  • Asynchronous Logic (Promises): Use Effect.tryPromise() to wrap native Promises and safely catch their rejections into typed Errors.
  • Legacy Callbacks: Use Effect.async(resume) to bridge older Node.js callback APIs (like fs.readFile) into modern Effects.
  • Execution: Effects do nothing until you hand them to the Runtime using Effect.runPromise(program) or Effect.runSync(program).

1. Introduction: Bridging the Gap
#

When migrating an application to Effect-TS, you will inevitably interact with third-party libraries (like Prisma, Axios, or the AWS SDK). These libraries do not know what an Effect is. They return native Promises, throw random exceptions, or use Node.js (err, data) callbacks.

To maintain architectural purity, we must “wrap” these external boundaries into the Effect ecosystem immediately. This is known as the Anti-Corruption Layer pattern.


2. Wrapping Pure and Synchronous Values
#

Static Values (succeed and fail)
#

If you have a value that is already computed and you simply want to lift it into the Effect pipeline, use Effect.succeed or Effect.fail.

import { Effect } from "effect";

// 🟢 Effect<number, never, never>
const staticSuccess = Effect.succeed(42);

// 🔴 Effect<never, string, never>
const staticFailure = Effect.fail("Something went wrong");

Lazy Synchronous Execution (sync)
#

If your synchronous code involves computation or side effects (like generating a random number or logging to the console), you must use Effect.sync.

// 🔴 Bad: The random number is generated immediately (Eager)
const badRandom = Effect.succeed(Math.random());

// 🟢 Good: The computation is deferred until runtime (Lazy)
const goodRandom = Effect.sync(() => Math.random());

3. Wrapping Asynchronous Promises
#

The vast majority of your I/O operations will be Promise-based.

The Unsafe Way (Effect.promise)
#

If you are 100% mathematically certain that a Promise will never reject, you can use Effect.promise().

const delay = Effect.promise(() =>
  new Promise<void>(resolve => setTimeout(resolve, 1000))
);

The Safe Way (Effect.tryPromise)
#

Almost all Promises (especially network requests or database queries) can reject. If you use Effect.promise() and it rejects, the Effect runtime treats it as an Unexpected Defect and crashes the fiber.

To safely catch the rejection and turn it into a Typed Error (E), you must use Effect.tryPromise().

const fetchUser = (id: string) => Effect.tryPromise({
  // 1. The Promise to execute
  try: () => fetch(`/api/users/${id}`).then(res => res.json()),

  // 2. The mapping function to catch the unknown rejection
  catch: (unknownError) => new Error(`Network failure: ${String(unknownError)}`)
});

// Signature: Effect<any, Error, never>

By providing the catch block, the compiler knows exactly what type of error this pipeline produces, ensuring you don’t forget to handle it later!


4. Wrapping Legacy Callbacks
#

If you are working with legacy Node.js APIs or event listeners, you will encounter the (err, data) => void callback pattern. You can wrap these using Effect.async.

Effect.async gives you a resume function. You call resume(Effect.succeed(...)) when the callback succeeds, or resume(Effect.fail(...)) when it fails.

import * as fs from "node:fs";
import { Effect } from "effect";

const readFileEffect = (path: string) => Effect.async<string, Error>(resume => {
  fs.readFile(path, "utf8", (err, data) => {
    if (err) {
      resume(Effect.fail(err));
    } else {
      resume(Effect.succeed(data));
    }
  });
});

// Signature: Effect<string, Error, never>

5. Effect Creators Comparison
#

Source DataUse ConstructorWhen to use
Static ValueEffect.succeed(val)Hardcoded values, pure logic results.
Sync ExecutionEffect.sync(() => doX())console.log, Math.random(), sync operations.
Safe PromiseEffect.promise(() => doX())Promises that literally cannot reject.
Unsafe PromiseEffect.tryPromise({ try, catch })Database calls, HTTP requests, file I/O.
CallbacksEffect.async(resume => ...)Event listeners, legacy Node.js APIs.

6. Executing the Pipeline
#

Once you have constructed your massive, lazy pipeline of Effects, it is time to execute it at the edge of your application.

Synchronous Execution
#

If your pipeline contains zero asynchronous boundaries (no Promises, no async callbacks), you can run it synchronously.

const syncProgram = Effect.sync(() => 10 * 10);

// Executes instantly and blocks the thread
const result = Effect.runSync(syncProgram);
console.log(result); // 100

Asynchronous Execution
#

If your pipeline contains even a single asynchronous boundary, you must run it returning a Promise.

const asyncProgram = Effect.tryPromise({ ... });

// Executes and returns a standard Promise
Effect.runPromise(asyncProgram).then(data => {
  console.log("Success:", data);
}).catch(err => {
  console.error("Pipeline Failed:", err);
});

(Note: There is also Effect.runFork() for background daemon execution, which we will cover in the Concurrency episode.)


7. Troubleshooting & Common Errors
#

Error 1: Fiber cannot be executed synchronously
#

Error: Fiber cannot be executed synchronously.

The Cause: You built a pipeline using Effect.promise or Effect.async, but you attempted to execute it using Effect.runSync(). The Fix: You cannot block a Node.js/V8 thread to wait for a Promise natively. You must use Effect.runPromise().

Error 2: Uncaught Defects from Effect.promise
#

The Mistake: You used Effect.promise() to wrap an Axios call, thinking the network would always succeed. When the network dropped, the Promise rejected. Because Effect.promise assumes no errors, the rejection was treated as a fatal defect, bypassing your catchAll blocks. The Fix: Always, always use Effect.tryPromise for network and I/O boundaries.


Summary & Next Steps
#

In this episode:

  • We wrapped static values using Effect.succeed and Effect.fail.
  • We deferred synchronous execution using Effect.sync.
  • We safely caught Promise rejections and typed them using Effect.tryPromise.
  • We bridged legacy callback hell into functional purity using Effect.async.
  • We executed our lazy pipelines at the edge of our application.

Now that we know how to push our Typed Errors (E) into the Effect pipeline, how do we handle them?

In Episode 64: Error Handling in Effect, we will explore how Effect-TS completely eliminates try/catch spaghetti code through elegant Pattern Matching!

typescript - This article is part of a series.
Part 63: This Article