Skip to content
Code & Context logoCode&Context

Effect.ts: The Prerequisites You Need Before the Library Clicks

Before Effect.ts feels natural, you need four mental models: generics, algebraic data types, functional composition, and laziness. This first post builds those foundations from first principles.

Saurabh Prakash

Author

Mar 31, 202613 min read
Share:

Most developers who bounce off Effect.ts are not failing because the library is obscure. They are failing because the prerequisites are implicit.

Effect does not just introduce new APIs. It assumes you are already comfortable with a particular way of reading TypeScript: generic abstractions, algebraic data types, composition-first control flow, and laziness.[1][2][3]


The Four Prerequisites

You can think of this post as four consecutive upgrades to how you model programs:

  1. Generics teach you to abstract over types without losing precision.[1]
  2. ADTs teach you to model states explicitly instead of relying on nulls and conventions.[2]
  3. Composition teaches you to express transformations as pipelines instead of nested control flow.[3]
  4. Laziness teaches you to distinguish between describing work and running work, which is the core move behind Effect itself.[4][5]

1. TypeScript Generics

The Core Idea

Generics let you write code that works across many types while preserving type information. The canonical example in the TypeScript handbook is the identity function:[1]

function identity<T>(x: T): T {
  return x;
}
 
const a = identity<string>("hello"); // type is string
const b = identity<number>(42);      // type is number

In the code above, T behaves like a type parameter. It carries information from the input position to the output position without collapsing to any.[1]

Effect is saturated with generic types. The library's central type is:

Effect<A, E, R>

Where:

  • A is the success type
  • E is the error type
  • R is the required environment

Mental model

Read a generic parameter as a blank that the compiler will fill later. T is not mystery syntax. It is a promise that the function will preserve a relationship between types.

Practice: Build a Generic Container

Start with a generic Box:

// Generic type
type Box<T> = {
  value: T;
};
 
const stringBox: Box<string> = { value: "Krishna" };
const numberBox: Box<number> = { value: 108 };

Now extend that idea with a transformation function:

// Tranformation function
function mapBox<T, U>(box: Box<T>, f: (x: T) => U): Box<U> {
  return { value: f(box.value) };
}

Function mapBox says:

  • Start with a Box<T>
  • Apply a function from T to U
  • End with a Box<U>

The container shape stays stable while the inside type changes.

Examples

// "doubled" transformer
const numberBox: Box<number> = { value: 5 };
const doubled = mapBox(numberBox, (n) => n * 2);
 
console.log(doubled); // { value: 10 }
 
// "uppercased" transformer
const stringBox: Box<string> = { value: "hello" };
const uppercased = mapBox(stringBox, (s) => s.toUpperCase());
 
console.log(uppercased); // { value: "HELLO" }
 
// "lengthBox" transformer
const stringBox: Box<string> = { value: "hello" };
const lengthBox = mapBox(stringBox, (s) => s.length);
 
console.log(lengthBox); // { value: 5 }
 
// "containsA" transformer
const stringBox: Box<string> = { value: "banana" };
const containsA = mapBox(stringBox, (s) => s.includes("a"));
 
console.log(containsA); // { value: true }

Why This Matters for Effect.ts

This exact pattern scales up directly:

  • Box<T> becomes Effect<A, E, R>
  • mapBox becomes Effect.map
  • Transforming the inside value without changing the container becomes standard library usage

Once you see generics as the language for describing containers and transformations, Effect's API stops feeling arbitrary.


2. Algebraic Data Types (ADTs)

The Core Idea

An algebraic data type lets data exist in one of several explicit shapes. In TypeScript, this is usually modeled with unions and a discriminant field like type or kind.[2]

The TypeScript handbook uses the term discriminated unions: when every branch shares a common literal field, a switch or equality check narrows the type safely.[2]

Example: Option

type Option<T> =
  | { type: "Some"; value: T }
  | { type: "None" };
 
const someValue: Option<number> = { type: "Some", value: 42 };
const noValue: Option<number> = { type: "None" };

Example: Either

type Either<E, A> =
  | { type: "Left"; error: E }
  | { type: "Right"; value: A };
 
const success: Either<string, number> = { type: "Right", value: 10 };
const failure: Either<string, number> = { type: "Left", error: "Oops" };

Pattern Matching by Switching on the Discriminant

function printOption<T>(opt: Option<T>): void {
  switch (opt.type) {
    case "Some":
      console.log("Got value:", opt.value);
      break;
    case "None":
      console.log("No value");
      break;
  }
}

This is more than stylistic neatness. It is how you teach the compiler that different branches carry different fields.[2]

Practice: Build a Result ADT

Start with two states:

type Result =
  | { type: "Success"; value: number }
  | { type: "Failure"; error: string };

Then pattern match with switch:

function handleResult(res: Result): void {
  switch (res.type) {
    case "Success":
      console.log("Got value:", res.value);
      break;
    case "Failure":
      console.log("Error:", res.error);
      break;
  }
}

Now extend it to something closer to real UI and async state:

type Result<A> =
  | { type: "Success"; value: A }
  | { type: "Failure"; error: string }
  | { type: "Loading" };

And then one step further:

type Result<A> =
  | { type: "Success"; value: A }
  | { type: "Failure"; error: string }
  | { type: "Loading" }
  | { type: "Empty" };
function handleResult<A>(res: Result<A>): void {
  switch (res.type) {
    case "Success":
      console.log("Got value:", res.value);
      break;
    case "Failure":
      console.log("Error:", res.error);
      break;
    case "Loading":
      console.log("Still loading...");
      break;
    case "Empty":
      console.log("No data yet");
      break;
  }
}

Add Helper Predicates

function isEmpty<A>(res: Result<A>): boolean {
  return res.type === "Empty";
}
 
function isSuccess<A>(res: Result<A>): boolean {
  return res.type === "Success";
}
 
function isFailure<A>(res: Result<A>): boolean {
  return res.type === "Failure";
}
 
function isLoading<A>(res: Result<A>): boolean {
  return res.type === "Loading";
}

What ADTs replace

ADTs replace vague states like null, undefined, magic booleans, and undocumented conventions. In Effect, this style shows up everywhere: Option, Either, Exit, Cause, stream pull results, and more.[2][5]

The diagram above illustrates how ADTs make illegal states harder to represent. You are modeling transitions explicitly instead of treating every state as an ad hoc object.


3. Functional Composition with pipe and flow

The Problem Without Composition

Suppose you want to uppercase a string, trim it, and add an exclamation mark:

const result = addExclamation(trim(toUpperCase(" hello ")));

It works, but nested function calls do not scale well. As transformations grow, the code becomes harder to read, harder to reorder, and harder to reason about.

Using pipe

Effect exposes pipe, letting you express transformations in the order the data flows:[3]

import { pipe } from "effect";
 
const result = pipe(
  " hello ",
  (s) => s.toUpperCase(),
  (s) => s.trim(),
  (s) => `${s}!`
);
 
console.log(result); // "HELLO!"

Using flow

flow creates a reusable pipeline.[3]

import { flow } from "effect";
 
const shout = flow(
  (s: string) => s.toUpperCase(),
  (s) => s.trim(),
  (s) => `${s}!`
);
 
console.log(shout(" hello ")); // "HELLO!"

pipe vs flow:

  • pipe applies functions to a value now
  • flow builds a function you can reuse later

Practice: A Number Pipeline with pipe

import { pipe } from "effect";
 
const start = 5;
 
const doubled = (n: number) => n * 2;
const toString = (n: number) => n.toString();
const addUnits = (s: string) => `${s} units`;
 
const result = pipe(
  start,
  doubled,
  toString,
  addUnits
);
 
console.log(result); // "10 units"

Rewrite the Same Idea with flow

import { flow } from "effect";
 
const doubled = (n: number) => n * 2;
const toString = (n: number) => n.toString();
const addUnits = (s: string) => `${s} units`;
 
const processNumber = flow(
  doubled,
  toString,
  addUnits
);
 
console.log(processNumber(5)); // "10 units"
console.log(processNumber(7)); // "14 units"

Combine Composition with ADTs

This is where the style begins to feel like real Effect code.

import { pipe } from "effect";
 
type Result<A> =
  | { type: "Success"; value: A }
  | { type: "Failure"; error: string }
  | { type: "Loading" }
  | { type: "Empty" };
 
function doubleIfSuccess(res: Result<number>): Result<number> {
  switch (res.type) {
    case "Success":
      return { type: "Success", value: res.value * 2 };
    default:
      return res;
  }
}
 
function toUnits(res: Result<number>): Result<string> {
  switch (res.type) {
    case "Success":
      return { type: "Success", value: `${res.value} units` };
    default:
      return res as Result<string>;
  }
}
 
const result = pipe(
  { type: "Success", value: 5 } as Result<number>,
  doubleIfSuccess,
  toUnits
);
 
console.log(result); // { type: "Success", value: "10 units" }

4. Lazy Evaluation

The Core Idea

Laziness means you describe a computation now and run it later. In JavaScript terms, this often means returning a function instead of a value.[4]

// Eager: computes right away
function eagerAdd(a: number, b: number): number {
  return a + b;
}
 
// Lazy: defers computation
function lazyAdd(a: number, b: number): () => number {
  return () => a + b;
}
 
const result = lazyAdd(2, 3); // no computation yet
console.log(result());        // computes here -> 5

Eager vs Lazy code:

  • Eager code produces a value immediately
  • Lazy code produces a description of how to get the value later

Why This Matters for Effect.ts

Effect values are lazy descriptions of work. They do not execute until you explicitly run them with a runtime entry point such as Effect.runPromise.[4][5]

That design buys you three things:

  1. Control over when effects happen
  2. Composability because descriptions can be combined before execution
  3. Safety because execution boundaries become explicit rather than accidental[4][5]

Practice: Lazy Computation

function heavyComputation(): number {
  console.log("Running expensive work...");
  return 42;
}
 
const lazyHeavy = () => heavyComputation();
 
console.log("Before calling");
console.log(lazyHeavy()); // prints message, then 42

Practice: Lazy Logger

function eagerLog(message: string): void {
  console.log("LOG:", message);
}
 
eagerLog("Hello"); // runs immediately
function lazyLog(message: string): () => void {
  return () => console.log("LOG:", message);
}
 
const logLater = lazyLog("Hello, world!");
 
console.log("Before logging...");
logLater(); // logs only now

Combine Laziness with ADTs

type Result<A> =
  | { type: "Success"; value: A }
  | { type: "Failure"; error: string }
  | { type: "Loading" }
  | { type: "Empty" };
 
const success: Result<number> = { type: "Success", value: 42 };
const failure: Result<number> = { type: "Failure", error: "Oops" };
 
function lazyHandleResult<A>(res: Result<A>): () => void {
  return () => {
    switch (res.type) {
      case "Success":
        console.log("Got value:", res.value);
        break;
      case "Failure":
        console.log("Error:", res.error);
        break;
      case "Loading":
        console.log("Still loading...");
        break;
      case "Empty":
        console.log("No data yet");
        break;
    }
  };
}
 
const lazySuccessLog = lazyHandleResult(success);
const lazyFailureLog = lazyHandleResult(failure);
 
console.log("Before calling...");
lazySuccessLog(); // Got value: 42
lazyFailureLog(); // Error: Oops

Combine Laziness with Composition

import { flow } from "effect";
 
const toDouble = (n: number) => n * 2;
const toString = (n: number) => n.toString();
const addUnits = (s: string) => `${s} units`;
 
const lazyPipeline = flow(
  toDouble,
  toString,
  addUnits
);
 
console.log("Before execution...");
console.log(lazyPipeline(5));          // "10 units"

The simple example above defines a pipeline, then execute it later. This is central to Effect philosophy.

A LazyResult ADT

To combine all four prerequisites in one place, make Success itself lazy:

type LazyResult<A> =
  | { type: "Success"; value: () => A }
  | { type: "Failure"; error: string }
  | { type: "Loading" }
  | { type: "Empty" };
 
const lazySuccess: LazyResult<number> = {
  type: "Success",
  value: () => 42 * 2
};
 
const lazyFailure: LazyResult<number> = {
  type: "Failure",
  error: "Oops"
};
 
function handleLazyResult<A>(res: LazyResult<A>): void {
  switch (res.type) {
    case "Success":
      console.log("Got value:", res.value());
      break;
    case "Failure":
      console.log("Error:", res.error);
      break;
    case "Loading":
      console.log("Still loading...");
      break;
    case "Empty":
      console.log("No data yet");
      break;
  }
}
 
handleLazyResult(lazySuccess); // Got value: 84
handleLazyResult(lazyFailure); // Error: Oops

The bridge to Effect

At this point you have all four ingredients in one construct: a generic container, multiple explicit states, composable transformations, and deferred execution.


A Practical Checkpoint

To check you understanding, ask yourself these questions:

  1. Can you explain why identity<string>("hello") returns a string without losing type safety?
  2. Can you model Success, Failure, Loading, and Empty as a discriminated union and handle all branches with switch?
  3. Can you rewrite a nested transformation as a pipe or flow pipeline?
  4. Can you explain the difference between creating a computation and executing it?

If any one of those still feels slippery, stay here longer. Effect gets easier when these ideas become reflexes.


Why This Matters More Than It Looks

Developers often try to learn Effect by memorizing combinators. That is backwards.

The real learning order is:

Effect is not hard because the library is weird. It is hard because it formalizes ideas that most JavaScript developers only use implicitly, inconsistently, or not at all.

Once the foundations lock into place, the library stops feeling abstract and starts feeling inevitable.


References

[1]: TypeScript Handbook, "Generics" — typescriptlang.org

[2]: TypeScript Handbook, "Narrowing" and discriminated unions — typescriptlang.org

[3]: Effect Documentation, "Building Pipelines" and code style guidance for pipe / Effect.gen — effect.website

[4]: MDN Web Docs, "Lazy loading" and JavaScript function evaluation patterns — developer.mozilla.org

[5]: Effect Documentation, introduction and runtime model, including explicit execution with Effect runtimes — effect.website