Effect.ts Result
Posted on Wed 19 August 2026 in Tech, TypeScript, Effect.ts
Since I discovered ZIO I have been in love with effect system. Effect.ts made it possible to use effect system in typescript, making it widely more useful due to the adoption of the language. I spent hours (probably days) digging into ZIO's codebase and learned a ton, it's now time to do the same with the Effect (v4) codebase.
Most of the tricks and really smart implementation of such frameworks are rarely needed in application code and are usually only required for library's author. I do believe that there is a lot of cool things to learn nonetheless.
My first focus was on higher-kinded types but in order to get there, we need a short detour: building a generator-based interpreter for one type constructor: Result.
Note that I will try to stay as close to the original declaration from the Effect codebase but might take a few shortcuts when it make sense.
Let's start with a simple definition of our container
interface interface Success<A, E>Success<function (type parameter) A in Success<A, E>A, function (type parameter) E in Success<A, E>E> {
readonly Success<A, E>._tag: "Success"_tag: "Success";
readonly Success<A, E>.value: Avalue: function (type parameter) A in Success<A, E>A;
}
interface interface Failure<A, E>Failure<function (type parameter) A in Failure<A, E>A, function (type parameter) E in Failure<A, E>E> {
readonly Failure<A, E>._tag: "Failure"_tag: "Failure";
readonly Failure<A, E>.error: Eerror: function (type parameter) E in Failure<A, E>E;
}
type type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<function (type parameter) A in type Result<A, E = never>A, function (type parameter) E in type Result<A, E = never>E = never> = interface Success<A, E>Success<function (type parameter) A in type Result<A, E = never>A, function (type parameter) E in type Result<A, E = never>E> | interface Failure<A, E>Failure<function (type parameter) A in type Result<A, E = never>A, function (type parameter) E in type Result<A, E = never>E>;
At the type level this is all we need to get start, let's now create a few method to build values:
function function succeed<A>(value: A): Result<A, never>succeed<function (type parameter) A in succeed<A>(value: A): Result<A, never>A>(value: Avalue: function (type parameter) A in succeed<A>(value: A): Result<A, never>A): type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<function (type parameter) A in succeed<A>(value: A): Result<A, never>A, never> {
return {
Success<A, never>._tag: "Success"_tag: "Success",
Success<A, never>.value: Avalue: value: Avalue
}
}
function function fail<E>(error: E): Result<never, E>fail<function (type parameter) E in fail<E>(error: E): Result<never, E>E>(error: Eerror: function (type parameter) E in fail<E>(error: E): Result<never, E>E): type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<never, function (type parameter) E in fail<E>(error: E): Result<never, E>E> {
return {
Failure<A, E>._tag: "Failure"_tag: "Failure",
Failure<never, E>.error: Eerror: error: Eerror
}
}
const successValue = function succeed<number>(value: number): Result<number, never>succeed(1)const failedValue = function fail<{
details: string;
}>(error: {
details: string;
}): Result<never, {
details: string;
}>
fail({details: stringdetails: "something wrong happend"})
This is working well but is not very useful, we are indeed lacking composition: we can't merge Results together. We need to implement map and flatmap.
And while Effect implements those, I'm going to focus on the more interesting part of effect: generator-based composition. There are a few requirements for this to work, and we now need to dive into iterators
function* function add(): Generator<any, any, unknown>add() {
const const a: anya = yield *succeed(1); const const b: anyb = yield *function succeed<number>(value: number): Result<number, never>succeed(2);
return const a: anya + const b: anyb;
}
The previous snippet is failing because yield* requires its operand to be iterable, we need to add [Symbol.iterator]().
Our iterator will describe two different values, it first yields the complete wrapper Result<A, E> and it then returns an A effectively become the value of the yield* expression.
TypeScript represents these two possible outcomes as IteratorResult<TYield, TReturn>. Here, TYield is Result<A, E> and TReturn is A.
We are first going to define a few type helper to improve readability:
type type SuccessValue<T> = T extends Result<infer A, any> ? A : neverSuccessValue<function (type parameter) T in type SuccessValue<T>T> = function (type parameter) T in type SuccessValue<T>T extends type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<infer function (type parameter) AA, any> ? function (type parameter) AA : never;
interface interface ResultIterator<T extends Result<any, any>>ResultIterator<function (type parameter) T in ResultIterator<T extends Result<any, any>>T extends type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<any, any>> {
ResultIterator<T extends Result<any, any>>.next(...args: ReadonlyArray<any>): IteratorResult<T, SuccessValue<T>>next(...args: readonly any[]args: interface ReadonlyArray<T>ReadonlyArray<any>): type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>IteratorResult<function (type parameter) T in ResultIterator<T extends Result<any, any>>T, type SuccessValue<T> = T extends Result<infer A, any> ? A : neverSuccessValue<function (type parameter) T in ResultIterator<T extends Result<any, any>>T>>;
}
declare const const value: numbervalue: type SuccessValue<T> = T extends Result<infer A, any> ? A : neverSuccessValue<interface Success<A, E>Success<number, never>>
declare const const error: nevererror: type SuccessValue<T> = T extends Result<infer A, any> ? A : neverSuccessValue<interface Failure<A, E>Failure<never, string>>
SuccessValue let us unpack the type of the success branch from a Result, while ResultIterator describes the type of the iterator returned by [Symbol.iterator]().
We can now add an iterator to our Result type, and Typescript is now happy with our type.
interface interface Success<A, E>Success<function (type parameter) A in Success<A, E>A, function (type parameter) E in Success<A, E>E> {
readonly Success<A, E>._tag: "Success"_tag: "Success";
readonly Success<A, E>.value: Avalue: function (type parameter) A in Success<A, E>A;
[var Symbol: SymbolConstructorSymbol.SymbolConstructor.iterator: typeof Symbol.iteratorA method that returns the default iterator for an object. Called by the semantics of the
for-of statement.iterator](): interface ResultIterator<T extends Result<any, any>>ResultIterator<type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<function (type parameter) A in Success<A, E>A, function (type parameter) E in Success<A, E>E>>
}
interface interface Failure<A, E>Failure<function (type parameter) A in Failure<A, E>A, function (type parameter) E in Failure<A, E>E> {
readonly Failure<A, E>._tag: "Failure"_tag: "Failure";
readonly Failure<A, E>.error: Eerror: function (type parameter) E in Failure<A, E>E;
[var Symbol: SymbolConstructorSymbol.SymbolConstructor.iterator: typeof Symbol.iteratorA method that returns the default iterator for an object. Called by the semantics of the
for-of statement.iterator](): interface ResultIterator<T extends Result<any, any>>ResultIterator<type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<function (type parameter) A in Failure<A, E>A, function (type parameter) E in Failure<A, E>E>>
}
declare const const a: Success<number, never>a: interface Success<A, E>Success<number, never>
declare const const b: Success<number, never>b: interface Success<A, E>Success<number, never>
function *function add(): Generator<Result<number, never>, number, any>add() {
const valueA = yield* const a: Success<number, never>a; const valueB = yield* const b: Success<number, never>b;
return const valueA: numbervalueA+const valueB: numbervalueB;
}
We however need to rework our constructors to take this new field into account:
function function succeed<A>(value: A): Result<A, never>succeed<function (type parameter) A in succeed<A>(value: A): Result<A, never>A>(value: Avalue: function (type parameter) A in succeed<A>(value: A): Result<A, never>A): type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<function (type parameter) A in succeed<A>(value: A): Result<A, never>A, never> {
const const self: Success<A, never>self: interface Success<A, E>Success<function (type parameter) A in succeed<A>(value: A): Result<A, never>A, never> = {
Success<A, never>._tag: "Success"_tag: "Success",
Success<A, never>.value: Avalue: value: Avalue,
[var Symbol: SymbolConstructorSymbol.SymbolConstructor.iterator: typeof Symbol.iteratorA method that returns the default iterator for an object. Called by the semantics of the
for-of statement.iterator]() {
// Not implemented yet
}
}
return const self: Success<A, never>self;
}
function function fail<E>(error: E): Result<never, E>fail<function (type parameter) E in fail<E>(error: E): Result<never, E>E>(error: Eerror: function (type parameter) E in fail<E>(error: E): Result<never, E>E): type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<never, function (type parameter) E in fail<E>(error: E): Result<never, E>E> {
const const self: Failure<never, E>self: interface Failure<A, E>Failure<never, function (type parameter) E in fail<E>(error: E): Result<never, E>E> = {
Failure<never, E>._tag: "Failure"_tag: "Failure",
Failure<never, E>.error: Eerror: error: Eerror,
[var Symbol: SymbolConstructorSymbol.SymbolConstructor.iterator: typeof Symbol.iteratorA method that returns the default iterator for an object. Called by the semantics of the
for-of statement.iterator]() {
// Not implemented yet
}
}
return const self: Failure<never, E>self;
}
Now we need to implement [Symbol.iterator](). It returns an iterator with two steps:
- next() yields the complete Result.
- next(value) finishes with value, which becomes the value of the yield* expression.
Effect calls this iterator SingleShotGen, I decided to call it SingleShotIterator to avoid
the confusion between the generator language feature (the object returned by generator function) and the Gen suffix that Effect uses
for its gen methods (we will see that in a bit).
class class SingleShotIterator<T, A>SingleShotIterator<function (type parameter) T in SingleShotIterator<T, A>T, function (type parameter) A in SingleShotIterator<T, A>A> implements interface IterableIterator<T, TReturn = any, TNext = any>Describes a user-defined
{@link
Iterator
}
that is also iterable.IterableIterator<function (type parameter) T in SingleShotIterator<T, A>T, function (type parameter) A in SingleShotIterator<T, A>A> {
private SingleShotIterator<T, A>.called: booleancalled: boolean = false;
private readonly SingleShotIterator<T, A>.self: Tself: function (type parameter) T in SingleShotIterator<T, A>T;
constructor(self: Tself: function (type parameter) T in SingleShotIterator<T, A>T) {
this.SingleShotIterator<T, A>.self: Tself = self: Tself;
}
SingleShotIterator<T, A>.next(value: A): IteratorResult<T, A>next(value: Avalue: function (type parameter) A in SingleShotIterator<T, A>A): type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>IteratorResult<function (type parameter) T in SingleShotIterator<T, A>T, function (type parameter) A in SingleShotIterator<T, A>A> {
if (this.SingleShotIterator<T, A>.called: booleancalled) {
return { IteratorReturnResult<A>.value: Avalue, IteratorReturnResult<A>.done: truedone: true };
}
this.SingleShotIterator<T, A>.called: booleancalled = true;
return { IteratorYieldResult<T>.value: Tvalue: this.SingleShotIterator<T, A>.self: Tself, IteratorYieldResult<T>.done?: false | undefineddone: false };
}
[var Symbol: SymbolConstructorSymbol.SymbolConstructor.iterator: typeof Symbol.iteratorA method that returns the default iterator for an object. Called by the semantics of the
for-of statement.iterator](): interface IterableIterator<T, TReturn = any, TNext = any>Describes a user-defined
{@link
Iterator
}
that is also iterable.IterableIterator<function (type parameter) T in SingleShotIterator<T, A>T, function (type parameter) A in SingleShotIterator<T, A>A> {
return new constructor SingleShotIterator<T, A>(self: T): SingleShotIterator<T, A>SingleShotIterator<function (type parameter) T in SingleShotIterator<T, A>T, function (type parameter) A in SingleShotIterator<T, A>A>(this.SingleShotIterator<T, A>.self: Tself);
}
}
Let's spend some time processing what is happening here: we are building an IterableIterator, it's an Iterator as it implement its protocol (having a next method returning a value and a status), and it's also a Iterable as it has a [Symbol.iterator] returning a new Iterator. The first time the next method is invoked we return the value passed in the constructor (in our case this will be Result<A,E>), next time the next function is called, it will return its input as the final return value of the iterator (in our case, the caller will have to unpack the result, and forward the inner value to next).
Now let's plug this Iterator into our constructors
function function succeed<A>(value: A): Result<A, never>succeed<function (type parameter) A in succeed<A>(value: A): Result<A, never>A>(value: Avalue: function (type parameter) A in succeed<A>(value: A): Result<A, never>A): type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<function (type parameter) A in succeed<A>(value: A): Result<A, never>A, never> {
const const self: Success<A, never>self: interface Success<A, E>Success<function (type parameter) A in succeed<A>(value: A): Result<A, never>A, never> = {
Success<A, never>._tag: "Success"_tag: "Success",
Success<A, never>.value: Avalue: value: Avalue,
[var Symbol: SymbolConstructorSymbol.SymbolConstructor.iterator: typeof Symbol.iteratorA method that returns the default iterator for an object. Called by the semantics of the
for-of statement.iterator]() {
return new constructor SingleShotIterator<Result<A, never>, A>(self: Result<A, never>): SingleShotIterator<Result<A, never>, A>SingleShotIterator<type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<function (type parameter) A in succeed<A>(value: A): Result<A, never>A>, function (type parameter) A in succeed<A>(value: A): Result<A, never>A>(const self: Success<A, never>self);
}
}
return const self: Success<A, never>self;
}
function function fail<E>(error: E): Result<never, E>fail<function (type parameter) E in fail<E>(error: E): Result<never, E>E>(error: Eerror: function (type parameter) E in fail<E>(error: E): Result<never, E>E): type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<never, function (type parameter) E in fail<E>(error: E): Result<never, E>E> {
const const self: Failure<never, E>self: interface Failure<A, E>Failure<never, function (type parameter) E in fail<E>(error: E): Result<never, E>E> = {
Failure<never, E>._tag: "Failure"_tag: "Failure",
Failure<never, E>.error: Eerror: error: Eerror,
[var Symbol: SymbolConstructorSymbol.SymbolConstructor.iterator: typeof Symbol.iteratorA method that returns the default iterator for an object. Called by the semantics of the
for-of statement.iterator]() {
return new constructor SingleShotIterator<Result<never, E>, never>(self: Result<never, E>): SingleShotIterator<Result<never, E>, never>SingleShotIterator<type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<never, function (type parameter) E in fail<E>(error: E): Result<never, E>E>, never>(const self: Failure<never, E>self);
}
}
return const self: Failure<never, E>self;
}
We are passing the Result as a value to the constructor of SingleShotIterator.
And with that our first test with real values:
function* function add(): Generator<Result<number, never>, number, any>add() {
const const a: numbera = yield *function succeed<number>(value: number): Result<number, never>succeed(1);
const const b: numberb = yield *function succeed<number>(value: number): Result<number, never>succeed(2);
return const a: numbera + const b: numberb;
}
But we now have a problem, our computation is trapped within this generator: we don't have a convenient way to consume its output. To read the final result of the generator we need the following code:
function* add() { const const a: numbera = yield *function succeed<number>(value: number): Result<number, never>succeed(1);
const const b: numberb = yield *function succeed<number>(value: number): Result<number, never>succeed(2);
return const a: numbera + const b: numberb;
}
const const generator: Generator<Result<number, never>, number, any>generator = function add(): Generator<Result<number, never>, number, any>add();
const const a: IteratorResult<Result<number, never>, number>a = const generator: Generator<Result<number, never>, number, any>generator.Generator<Result<number, never>, number, any>.next(...[value]: [] | [any]): IteratorResult<Result<number, never>, number>next();
if(!const a: IteratorResult<Result<number, never>, number>a.done?: boolean | undefineddone && const a: IteratorYieldResult<Result<number, never>>a.IteratorYieldResult<Result<number, never>>.value: Result<number, never>value._tag: "Success" | "Failure"_tag === "Success") {
const const b: IteratorResult<Result<number, never>, number>b = const generator: Generator<Result<number, never>, number, any>generator.Generator<Result<number, never>, number, any>.next(...[value]: [] | [any]): IteratorResult<Result<number, never>, number>next(const a: IteratorYieldResult<Result<number, never>>a.IteratorYieldResult<Result<number, never>>.value: Success<number, never>value.Success<number, never>.value: numbervalue);
if(!const b: IteratorResult<Result<number, never>, number>b.done?: boolean | undefineddone && const b: IteratorYieldResult<Result<number, never>>b.IteratorYieldResult<Result<number, never>>.value: Result<number, never>value._tag: "Success" | "Failure"_tag === "Success") {
const const result: IteratorResult<Result<number, never>, number>result = const generator: Generator<Result<number, never>, number, any>generator.Generator<Result<number, never>, number, any>.next(...[value]: [] | [any]): IteratorResult<Result<number, never>, number>next(const b: IteratorYieldResult<Result<number, never>>b.IteratorYieldResult<Result<number, never>>.value: Success<number, never>value.Success<number, never>.value: numbervalue);
if(const result: IteratorResult<Result<number, never>, number>result.done?: boolean | undefineddone) {
var console: Consoleconsole.Console.log(...data: any[]): voidThe **`console.log()`** static method outputs a message to the console.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log(const result: IteratorReturnResult<number>result.value) }
}
}
This cannot scale: we need to support an arbitrary number of yield, mixed types (both in the return channel and the error channel).
In order to solve this problem we need to introduce a interpreter that's going to perform all of that for us, in Effect those usually are functions called gen. For our Result type the interpreter will be fairly easy:
- create a generator
- as long as the iterator is not done, consume values from it, if we receive a Failure, stop there and return it, otherwise unpack the value and send it back (
generator.next(b.value.value)) - wrap and return the final value
type type ErrorOf<K> = [K] extends [never] ? never : [K] extends [Result<any, infer E>] ? E : neverErrorOf<function (type parameter) K in type ErrorOf<K>K> = [function (type parameter) K in type ErrorOf<K>K] extends [never]
? never
: [function (type parameter) K in type ErrorOf<K>K] extends [type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<any, infer function (type parameter) EE>]
? function (type parameter) EE
: never;
function function gen<K extends Result<any, any>, A>(generator: () => Generator<K, A, any>): Result<A, ErrorOf<K>>gen<function (type parameter) K in gen<K extends Result<any, any>, A>(generator: () => Generator<K, A, any>): Result<A, ErrorOf<K>>K extends type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<any, any>, function (type parameter) A in gen<K extends Result<any, any>, A>(generator: () => Generator<K, A, any>): Result<A, ErrorOf<K>>A>(
generator: () => Generator<K, A, any>generator: () => interface Generator<T = unknown, TReturn = any, TNext = any>Generator<function (type parameter) K in gen<K extends Result<any, any>, A>(generator: () => Generator<K, A, any>): Result<A, ErrorOf<K>>K, function (type parameter) A in gen<K extends Result<any, any>, A>(generator: () => Generator<K, A, any>): Result<A, ErrorOf<K>>A, any>,
): type Result<A, E = never> = Success<A, E> | Failure<A, E>Result<function (type parameter) A in gen<K extends Result<any, any>, A>(generator: () => Generator<K, A, any>): Result<A, ErrorOf<K>>A, type ErrorOf<K> = [K] extends [never] ? never : [K] extends [Result<any, infer E>] ? E : neverErrorOf<function (type parameter) K in gen<K extends Result<any, any>, A>(generator: () => Generator<K, A, any>): Result<A, ErrorOf<K>>K>> {
const const body: Generator<K, A, any>body = generator: () => Generator<K, A, any>generator();
let let next: IteratorResult<K, A>next = const body: Generator<K, A, any>body.Generator<K, A, any>.next(...[value]: [] | [any]): IteratorResult<K, A>next();
while (!let next: IteratorResult<K, A>next.done?: boolean | undefineddone) {
if (let next: IteratorYieldResult<K>next.IteratorYieldResult<K>.value: Result<any, any>value._tag: "Success" | "Failure"_tag === "Failure") return let next: IteratorYieldResult<K>next.IteratorYieldResult<K>.value: Failure<any, any>value;
let next: IteratorResult<K, A>next = const body: Generator<K, A, any>body.Generator<K, A, any>.next(...[value]: [] | [any]): IteratorResult<K, A>next(let next: IteratorYieldResult<K>next.IteratorYieldResult<K>.value: Success<any, any>value.Success<any, any>.value: anyvalue);
}
return function succeed<A>(value: A): Result<A, never>succeed(let next: IteratorReturnResult<A>next.IteratorReturnResult<A>.value: Avalue);
}
function* add() { const const a: numbera = yield *function succeed<number>(value: number): Result<number, never>succeed(1);
const const b: numberb = yield *function succeed<number>(value: number): Result<number, never>succeed(2);
return const a: numbera + const b: numberb;
}
const result = function gen<Result<number, never>, number>(generator: () => Generator<Result<number, never>, number, any>): Result<number, never>gen(function add(): Generator<Result<number, never>, number, any>add)
If we trace the execution, the following things are happening:
caller
gen
add
iterator