Skip to content

Commit 743f1c2

Browse files
committed
📃 docs(README): Improve sections for pipeline syntax
1 parent a72a4ee commit 743f1c2

File tree

3 files changed

+998
-54
lines changed

3 files changed

+998
-54
lines changed

README.md

Lines changed: 216 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,68 +1135,253 @@ const range = (start: number, stop: number) => range(start, stop).with(handleErr
11351135
const range = (start: number, stop: number) => handleErrorAsResult(range(start, stop));
11361136
```
11371137

1138-
### Effects without generators (Pipeline syntax)
1138+
### Parallel execution with `Effected.all`
11391139

1140-
The fundamental logic of tinyeffect is _not_ dependent on generators. An effected program (represented as an `Effected` instance) is essentially an iterable object that implements a `[Symbol.iterator](): Iterator<Effect>` method. Although using the `effected` helper function in conjunction with a generator allows you to write more imperative-style code with `yield*` to manage effects, this is not the only way to handle them.
1140+
When working with asynchronous effects, you frequently need to combine multiple operations. While generator syntax excels at expressing sequential code, it doesn’t provide a native way to run effects in parallel using `yield*`. To address this, tinyeffect offers two complementary methods for handling multiple effected programs:
11411141

1142-
In fact, `effected` can accept any function that returns an iterator of effects — specifically, any function that returns an object implementing a `.next()` method that outputs objects with `value` and `done` properties.
1142+
- `Effected.all()`: Executes effected programs in parallel (concurrently)
1143+
- `Effected.allSeq()`: Executes effected programs sequentially (one after another), equivalent to running them individually with `yield*`.
11431144

1144-
It is not even necessary to use `effected` to construct an effected program. You can also create them using `Effected.of()` or `Effected.from()`. Here are two equivalent examples:
1145+
It’s worth noting that when all effected programs are synchronous, `Effected.all()` and `Effected.allSeq()` produce identical results. The difference becomes significant when dealing with time-consuming operations:
11451146

11461147
```typescript
1147-
const fib1 = (n: number): Effected<never, number> =>
1148+
const fetchUserData = (userId: number) =>
11481149
effected(function* () {
1149-
if (n <= 1) return n;
1150-
return (yield* fib1(n - 1)) + (yield* fib1(n - 2));
1150+
yield* log(`Fetching user ${userId}`);
1151+
const data = yield* httpGet(`/api/users/${userId}`);
1152+
return data;
11511153
});
11521154

1153-
const fib2 = (n: number): Effected<never, number> => {
1154-
if (n <= 1) return Effected.of(n);
1155-
// Or use `Effected.from` with a getter:
1156-
// if (n <= 1) return Effected.from(() => n);
1157-
return fib2(n - 1).andThen((a) => fib2(n - 2).andThen((b) => a + b));
1158-
};
1155+
// Sequential execution - total time is the sum of all operations
1156+
const sequentialFetch = Effected.allSeq([fetchUserData(1), fetchUserData(2), fetchUserData(3)]); // Takes ~300ms if each fetch takes ~100ms
1157+
1158+
// Parallel execution - total time is close to the slowest operation
1159+
const parallelFetch = Effected.all([fetchUserData(1), fetchUserData(2), fetchUserData(3)]); // Takes ~100ms because all fetches run concurrently
11591160
```
11601161

1161-
> [!NOTE]
1162-
>
1163-
> The above example is purely for illustrative purposes and _should not_ be used in practice. While it demonstrates how effects can be handled, it mimics the behavior of a simple fib function with unnecessary complexity and overhead, which could greatly degrade performance.
1162+
Both methods accept either an iterable of effected programs or an object with named effected programs:
11641163

1165-
Understanding the definition of `fib2` may take some time, but it serves as an effective demonstration of working with effects without generators. The expression `fib2(n - 1).andThen((a) => fib2(n - 2).andThen((b) => a + b))` can be interpreted as follows: “After resolving `fib2(n - 1)`, assign the result to `a`, then resolve `fib2(n - 2)` and assign the result to `b`. Finally, return `a + b`.”
1164+
```typescript
1165+
// Iterable syntax - results will be an array
1166+
const users = await Effected.all([fetchUser(1), fetchUser(2), fetchUser(3)]).runAsync();
1167+
// users: [User, User, User]
11661168

1167-
You can compare `Effected` with `Promise` in JavaScript. Just like `Promise.prototype.then(handler)` allows you to chain multiple promises together, `Effected.prototype.andThen(handler)` allows you to chain multiple effected programs together. If a handler returns a generator or another effected program, it will be automatically flattened, similar to how `Promise.prototype.then()` works in JavaScript.
1169+
// Object syntax - results maintain property names
1170+
const userData = await Effected.all({
1171+
user: fetchUser(userId),
1172+
posts: fetchUserPosts(userId),
1173+
settings: fetchUserSettings(userId),
1174+
}).runAsync();
1175+
// userData: { user: User, posts: Post[], settings: Settings }
1176+
```
1177+
1178+
You can mix synchronous and asynchronous effects, and `Effected.all` will handle them efficiently:
1179+
1180+
```typescript
1181+
const compute = effect("compute")<[label: string, delay: number], number>;
1182+
const calculate = effect("calculate")<[a: number, b: number], number>;
1183+
1184+
// Create a mix of sync and async tasks
1185+
const program = effected(function* () {
1186+
const results = yield* Effected.all([
1187+
// Sync task
1188+
calculate(10, 5),
1189+
// Fast async task
1190+
compute("fast task", 50),
1191+
// Slow async task
1192+
compute("slow task", 150),
1193+
]);
1194+
console.log("Results:", results);
1195+
})
1196+
.resume("calculate", (a, b) => a + b)
1197+
.handle("compute", ({ resume }, label, delay) => {
1198+
console.log(`Starting ${label}`);
1199+
setTimeout(() => {
1200+
console.log(`Completed ${label}`);
1201+
resume(delay);
1202+
}, delay);
1203+
});
1204+
1205+
// Results will be [15, 50, 150]
1206+
// Total execution time will be ~150ms (the slowest task)
1207+
```
11681208

1169-
Another way to think of `Effected` is as a container for a delayed computation (or _monad_, if you come from a functional programming background). The `Effected` instance itself doesn't perform any computation; it only represents a sequence of effects that will be executed when you call `.runSync()` or `.runAsync()`.
1209+
When should you choose sequential execution with `Effected.allSeq`? Consider using it when:
11701210

1171-
Actually, tinyeffect provides two more methods, `.map()` and `.flatMap()`, which explicitly indicate whether you want to flatten the result or not. The `fib2` function can be rewritten using `flatMap/map` as follows:
1211+
1. Operations must happen in a specific order.
1212+
2. Later operations depend on earlier ones.
1213+
3. You need to limit resource usage by preventing concurrent operations.
11721214

11731215
```typescript
1174-
const fib2 = (n: number): Effected<never, number> => {
1175-
if (n <= 1) return Effected.of(n);
1176-
return fib2(n - 1).flatMap((a) => fib2(n - 2).map((b) => a + b));
1177-
};
1216+
// Use sequential execution when operations must happen in order
1217+
const processData = Effected.allSeq([
1218+
setupDatabase(),
1219+
migrateSchema(),
1220+
importData(),
1221+
validateData(),
1222+
]);
1223+
1224+
// The equivalent generator syntax
1225+
const processData = effected(function* () {
1226+
yield* setupDatabase();
1227+
yield* migrateSchema();
1228+
yield* importData();
1229+
yield* validateData();
1230+
});
1231+
```
1232+
1233+
For most other cases, it is recommended to use `Effected.all` over `Effected.allSeq`, since it is more efficient and easier to read.
1234+
1235+
### Effects without generators (Pipeline syntax)
1236+
1237+
The fundamental logic of tinyeffect is _not_ dependent on generators. An effected program (represented as an `Effected` instance) is essentially an iterable object that implements a `[Symbol.iterator](): Iterator<Effect>` method.
1238+
1239+
Although using the `effected` helper function with generators allows you to write more imperative-style code using `yield*` to manage effects, this is not the only approach. tinyeffect offers an alternative pipeline-style API for transforming and combining effected programs. At the heart of this API is the `.andThen()` method, which serves as the primary way to transform and chain effected programs.
1240+
1241+
While we've covered `.andThen()` in previous sections, we haven’t yet explored how it can be used in a more functional, pipeline-style manner. The `.andThen(handler)` method is quite versatile and can be used in several ways:
1242+
1243+
While we have covered `.andThen()` in previous sections, we haven’t yet explored how it can be used in a more functional, pipeline-style manner. Actually, `.andThen(handler)` is quite versatile and can be used in a variety of ways:
1244+
1245+
- Transform a result using a pure function.
1246+
- Chain another effected program.
1247+
- Work with generators that yield effects.
1248+
1249+
Let’s rewrite the `createUser` example using the pipeline syntax:
1250+
1251+
```typescript
1252+
const createUser = (user: Omit<User, "id">) =>
1253+
requiresAdmin()
1254+
.andThen(() =>
1255+
executeSQL("INSERT INTO users (name) VALUES (?)", user.name)
1256+
.andThen((id) => ({ id, ...user } as User)),
1257+
)
1258+
.tap((savedUser) => println("User created:", savedUser));
11781259
```
11791260

1180-
While `andThen` is more concise and easier to read, `flatMap` and `map` provide more control over the result. However, it is not recommended to use `flatMap` and `map` instead of `andThen` directly in most cases, as they can make the code harder to read and understand, and provide very little improvement in performance.
1261+
A helpful way to understand this code is to think of `Effected` as a container for a delayed computation (or _monad_, if you come from a functional programming background). The `Effected` instance itself doesn’t perform any computation; it only represents a sequence of effects that will be executed when you call `.runSync()` or `.runAsync()`.
1262+
1263+
You can compare `Effected` with `Promise` in JavaScript. Just like `Promise.prototype.then(handler)` allows you to chain multiple promises together, `Effected.prototype.andThen(handler)` allows you to chain multiple effected programs together. If a handler returns a generator or another effected program, it will be automatically flattened, similar to how `Promise.prototype.then()` works in JavaScript.
11811264

1182-
There’s also an `.as(value)` method which is an alias for `.map(() => value)`. This can be useful when you want to return a constant value from an effect:
1265+
To create effects without generators, tinyeffect provides two foundational methods. `Effected.of(value)` creates an effected program that immediately resolves to the given value without performing any effects — similar to `Promise.resolve(value)`. `Effected.from(() => value)` allows you to execute a function lazily when the program is run. These are useful as starting points for pipeline-style code:
1266+
1267+
```typescript
1268+
// Create an effected program that resolves to "Hello, world!"
1269+
const program1 = Effected.of("Hello, world!")
1270+
.tap((message) => println(message))
1271+
.andThen((message) => message.toUpperCase());
1272+
1273+
// Create an effected program that executes the function when run
1274+
const program2 = Effected.from(() => {
1275+
console.log("Computing value...");
1276+
return Math.random() * 100;
1277+
}).andThen((value) => println(`Random value: ${value}`));
1278+
```
1279+
1280+
When you need more explicit control, tinyeffect offers `.map()` and `.flatMap()`. The `.map()` method transforms a result without introducing new effects, while `.flatMap()` expects the handler to return another effected program:
1281+
1282+
```typescript
1283+
const createUser = (user: Omit<User, "id">) =>
1284+
requiresAdmin()
1285+
.flatMap(() =>
1286+
executeSQL("INSERT INTO users (name) VALUES (?)", user.name)
1287+
.map((id) => ({ id, ...user } as User)),
1288+
)
1289+
.tap((savedUser) => println("User created:", savedUser));
1290+
```
1291+
1292+
For the common case of replacing a result with a constant value, use the `.as(value)` method as a shorthand for `.map(() => value)`:
11831293

11841294
```typescript
11851295
Effected.of(42).as("Hello, world!").runSync(); // => "Hello, world!"
11861296
// You can also use `.asVoid()` as a shortcut for `.as(undefined)`
11871297
Effected.of(42).asVoid().runSync(); // => undefined
11881298
```
11891299

1190-
We also provide an `Effected.all()` helper function to run multiple effected programs in parallel and return their results as an array. This is similar to `Promise.all()`, but it works with effects instead of promises. Here’s an example:
1300+
In most cases, `.andThen()` is recommended over `.map()` and `.flatMap()` for its versatility and readability. A myth is that `.flatMap()/.map()` may provide better performance than `.andThen()` since they do not need to check if the handler returns a generator or another effected program. However, in practice, the performance difference is negligible, so it’s better to use `.andThen()` directly for simplicity and consistency.
1301+
1302+
### Pipeline Syntax V.S. Generator Syntax
1303+
1304+
Both pipeline syntax and generator syntax are valid approaches for working with effected programs in tinyeffect. Each approach has distinct advantages:
1305+
1306+
**Generator Syntax:**
1307+
1308+
- More familiar to developers used to imperative programming.
1309+
- Natural handling of conditionals and loops.
1310+
- Simpler debugging with sequential steps.
1311+
1312+
**Pipeline Syntax:**
1313+
1314+
- More functional approach with method chaining.
1315+
- Reduces nesting for simple transformations.
1316+
- Offers better performance in some cases.
1317+
1318+
While pipeline syntax offers better performance for simple transformations, in reality such advantages are often negligible since IO-bound effects (like HTTP requests or file operations) usually dominate the execution time. Therefore, the choice between the two should primarily be based on readability and maintainability.
1319+
1320+
Below are several examples where pipeline syntax might seem more straightforward:
11911321

11921322
```typescript
1193-
Effected.all([Effected.of(1), Effected.of(2), Effected.of(3)]).runSync(); // => [1, 2, 3]
1323+
// Generator syntax
1324+
const getUserPosts = (userId: number) =>
1325+
effected(function* () {
1326+
const user = yield* fetchUser(userId);
1327+
if (!user) return null;
1328+
return yield* fetchPosts(user.id);
1329+
});
1330+
1331+
// Pipeline syntax
1332+
const getUserPosts = (userId: number) =>
1333+
fetchUser(userId).andThen((user) => {
1334+
if (!user) return null;
1335+
return fetchPosts(user.id);
1336+
});
11941337
```
11951338

1196-
To run effect programs sequentially, use `Effected.allSeq()`:
1339+
Another example for error handling:
11971340

11981341
```typescript
1199-
Effected.allSeq([Effected.of(1), Effected.of(2), Effected.of(3)]).runSync(); // => [1, 2, 3]
1342+
// Generator syntax
1343+
const processFile = (path: string) =>
1344+
effected(function* () {
1345+
const content = yield* readFile(path);
1346+
return yield* parseContent(content);
1347+
}).catchAll(function* (error, message) {
1348+
yield* logger.error(`[${error}Error] Error processing ${path}:`, message);
1349+
return null;
1350+
});
1351+
1352+
// Pipeline syntax
1353+
const processFile = (path: string) =>
1354+
readFile(path)
1355+
.andThen((content) => parseContent(content))
1356+
.catchAll((error, message) =>
1357+
logger.error(`[${error}Error] Error processing ${path}:`, message).as(null),
1358+
);
12001359
```
12011360

1202-
Note that when all effected programs are synchronous, `Effected.all()` and `Effected.allSeq()` behave identically.
1361+
However, when dealing with complex control flow, generator syntax might be more readable:
1362+
1363+
```typescript
1364+
// Generator syntax
1365+
const submitOrder = (order: Order) =>
1366+
effected(function* () {
1367+
const [config, user] = yield* Effected.all([askConfig(), askCurrentUser()]);
1368+
yield* validateOrder(order, user);
1369+
const result = yield* saveOrder(order, config.apiUrl);
1370+
yield* sendNotification(user.email, "Order submitted");
1371+
return result;
1372+
});
1373+
1374+
// Pipeline syntax
1375+
const submitOrder = (order: Order) =>
1376+
Effected.all([askConfig(), askCurrentUser()]).andThen(([config, user]) =>
1377+
validateOrder(order, user).andThen(() =>
1378+
saveOrder(order, config.apiUrl).tap(() =>
1379+
sendNotification(user.email, "Order submitted").asVoid(),
1380+
),
1381+
),
1382+
);
1383+
```
1384+
1385+
While the pipeline syntax shown above is more compact, it may not be as readable as the generator syntax for most developers since it involves more nesting.
1386+
1387+
Both generator syntax and pipeline syntax are fully supported in tinyeffect — choose whichever approach makes your code most readable and maintainable for you and your team. The best choice often depends on the specific task and your team’s preferences.

0 commit comments

Comments
 (0)