pipe
pipe
is a very important function. It’s so important that it is available as a top level import.
import { pipe } from "effect";
The pipe function is so essential for functional programming that many functional programming languages bake this into the language with special syntax. There’s even a proposal to introduce a pipe operator to JavaScript.
pipe works very well with Effects, but there is nothing Effect-specific about it. Let’s see an example.
import { pipe } from "effect";
const double = (x: number) => x * 2;
const add = (x: number) => (y: number) => x + y;
const format = (x: number) => `The answer is ${x}`;
const piped = pipe(
5,
double, // 10
double, // 20
add(1), // 21
double, // 42
format, // "The answer is 42"
);
console.log(piped);
Let’s see what this code would look like without pipe:
const v0 = 5;
const v1 = double(v0); // 10
const v2 = double(v1); // 20
const v3 = add(1)(v2); // 21
const v4 = double(v3); // 42
const v5 = format(v4); // "The answer is 42"
console.log(v5);
If we tried to write it as a single statement, the result would be very hard to read:
const result = format(double(add(1)(double(double(5)))));
In this squashed version, the data moves from right to left, but the nested parentheses make it hard to read. In the piped version, we can imagine the data value flowing from top to bottom. In an object-oriented language, we might use method chaining to achieve a similar result:
const arrayResult = [5]
.map(double) // [10]
.map(double) // [20]
.map(add(1)) // [21]
.map(double) // [42]
.map(format); // ["The answer is 42"]
console.log(arrayResult);
By defining a basic helper function, we can convert this method-chaining version to a more “functional” style with pipe:
type ArrayMap = <T, U>(fn: (t: T) => U) => (arr: T[]) => U[];
const mapArray: ArrayMap = (fn) => (arr) => arr.map(fn);
const pipedArray = pipe(
[5],
mapArray(double), // [10]
mapArray(double), // [20]
mapArray(add(1)), // [21]
mapArray(double), // [42]
mapArray(format), // ["The answer is 42"]
);
console.log(pipedArray);
We could also do the same thing with Promises:
const chainedPromise = Promise.resolve(5)
.then(double) // Promise<10>
.then(double) // Promise<20>
.then(add(1)) // Promise<21>
.then(double) // Promise<42>
.then(format) // Promise<"The answer is 42">
.then(v => console.log(v));
type PromiseMap = <T, U>(fn: (t: T) => U) => (p: Promise<T>) => Promise<U>;
const mapPromise: PromiseMap = (fn) => (promise) => promise.then(fn);
const pipedPromise = pipe(
Promise.resolve(5),
mapPromise(double), // Promise<10>
mapPromise(double), // Promise<20>
mapPromise(add(1)), // Promise<21>
mapPromise(double), // Promise<42>
mapPromise(format), // Promise<"The answer is 42">
mapPromise(v => console.log(v)),
);
Which brings us on to Effects. Recall that there are very few methods defined on the Effect object, but that there are many functions that can operate on an Effect. Just like we chained a “map” operation over an Array or a Promise, we can also chain map operations over an Effect.
const pipedEffect = pipe(
Effect.succeed(5),
Effect.map(double), // Effect<10>
Effect.map(double), // Effect<20>
Effect.map(add(1)), // Effect<21>
Effect.map(double), // Effect<42>
Effect.map(format), // Effect<"The answer is 42">
);
console.log(Effect.runSync(pipedEffect));
Note that the type can change from line to line. All that matters is that the output type of one function matches the input type of the next function. We could write and execute our entire script as a single pipeline.
pipe(
5,
Effect.succeed, // Effect<5>
Effect.map(double), // Effect<10>
Effect.map(double), // Effect<20>
Effect.map(add(1)), // Effect<21>
Effect.map(double), // Effect<42>
Effect.map(format), // Effect<"The answer is 42">
Effect.map(console.log), // Effect<undefined>
Effect.runSync, // undefined
);
pipe
is so convenient when working with Effect
that it is exposed as a method on Effect
instances. Since Effect.map
returns an Effect, we can also call .pipe
on the return value, allowing us to get back to a method chaining style, if we want. Here’s another way of writing that same code:
Effect.succeed(5)
.pipe(Effect.map(double)) // Effect<10>
.pipe(Effect.map(double)) // Effect<20>
.pipe(Effect.map(add(1))) // Effect<21>
.pipe(Effect.map(double)) // Effect<42>
.pipe(Effect.map(format)) // Effect<"The answer is 42">
.pipe(Effect.map(console.log)) // Effect<undefined>
.pipe(Effect.runSync); // undefined
Let’s revisit the validation example from Effect.flatMap. Here you can see how the handful of tools we’ve learned so far can help us to make a simple validation library, a bit like Zod or Yup. Unlike those libraries, we use piping instead of chaining.
class ValidationError extends Error {
_kind = "ValidationError" as const;
}
const isString = (x: unknown) =>
typeof x === "string"
? Effect.succeed(x)
: Effect.fail(new ValidationError(`${x} is not a string`));
const isUrl = (x: string) => {
try {
return Effect.succeed(new URL(x));
} catch (e) {
return Effect.fail(new ValidationError(`${x} is not a URL`));
}
};
const minLength = (length: number) => (x: string) =>
x.length >= length
? Effect.succeed(x)
: Effect.fail(new ValidationError(`${x} is too short`));
const validateHostname = (value: unknown) =>
Effect.succeed(value).pipe(
Effect.flatMap(isString),
Effect.flatMap(minLength(5)),
Effect.map((s) => s.toLowerCase()),
Effect.flatMap(isUrl),
Effect.map((url) => url.hostname),
);
validateHostname("https://EFFECT.website").pipe(
Effect.map(console.log),
Effect.runSync,
);
try {
validateHostname(1234).pipe(
Effect.map(console.log),
Effect.runSync,
);
} catch (e) {
console.error(e);
}
try {
validateHostname("no").pipe(
Effect.map(console.log),
Effect.runSync,
);
} catch (e) {
console.error(e);
}
It’s worth noting one last thing about pipe
. Each function that gets passed into pipe
must take precisely one argument. For functions that take multiple arguments, we may need to do some wrangling.
// add(x)(y)
const add = (x: number) => (y: number) => x + y;
// multiply(x, y)
const multiply = (x: number, y: number) => x * y;
const result = pipe(
5,
add(2),
v => multiply(v, 6),
);
console.log(result);
This is something to bear in mind when writing functions that are designed to be pipe-friendly. It might seem a little odd at first, but writing functions like add(x)(y)
may be easier in the long run that multiply(x, y)
. Many functions in the Effect ecosystem (such as Effect.map
and Effect.flatMap
) expose a dual API, to offer the best of both worlds.
Let’s recap:
- Effect-the-library contains functional helpers like
pipe
, not just Effect-the-module. pipe
helps us write more readable functional code that composes.pipe
works great in combination withEffect
s.- We can use
pipe
with any data type, including standard types like Array and Promise. pipe(x, f, g)
is equivalent tog(f(x))
.pipe
executes functions left-to-right (or top-to-bottom)pipe(effect, fnA, fnB)
andeffect.pipe(fnA, fnB)
are equivalent.