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
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:
- Generics teach you to abstract over types without losing precision.[1]
- ADTs teach you to model states explicitly instead of relying on nulls and conventions.[2]
- Composition teaches you to express transformations as pipelines instead of nested control flow.[3]
- 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 numberIn 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:
Ais the success typeEis the error typeRis 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
TtoU - 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>becomesEffect<A, E, R>mapBoxbecomesEffect.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";
}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:
pipeapplies functions to a value nowflowbuilds 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 -> 5Eager 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:
- Control over when effects happen
- Composability because descriptions can be combined before execution
- 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 42Practice: Lazy Logger
function eagerLog(message: string): void {
console.log("LOG:", message);
}
eagerLog("Hello"); // runs immediatelyfunction lazyLog(message: string): () => void {
return () => console.log("LOG:", message);
}
const logLater = lazyLog("Hello, world!");
console.log("Before logging...");
logLater(); // logs only nowCombine 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: OopsCombine 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: OopsThe 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:
- Can you explain why
identity<string>("hello")returns astringwithout losing type safety? - Can you model
Success,Failure,Loading, andEmptyas a discriminated union and handle all branches withswitch? - Can you rewrite a nested transformation as a
pipeorflowpipeline? - 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