@nlozgachev/pipelined 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +223 -68
  2. package/package.json +2 -1
package/README.md CHANGED
@@ -15,11 +15,13 @@ npm add @nlozgachev/pipelined
15
15
  ## Possibly maybe
16
16
 
17
17
  In mainstream TypeScript, code is often burdened by implicit control flow: unchecked exceptions,
18
- manual null propagation, and unhandled asynchronous failures. `pipelined` turns these complex
19
- runtime states into simple, transparent data structures that compose. By representing optionality as
20
- `Maybe`, failures as `Result`, lazy asynchronous pipelines as `Task.Result`, and repeated stateful
21
- interactions as `Op` and `Stream`, the library helps disentangle business logic from control
22
- mechanics.
18
+ manual null propagation, unhandled asynchronous failures, UI state race conditions, and deeply
19
+ nested spread operators. `pipelined` turns these complex runtime states into transparent, composable
20
+ data structures.
21
+
22
+ By representing optionality as `Maybe`, failures as `Result`, multi-field errors as `Validation`,
23
+ lazy asynchronous workflows as `Task.Result`, request concurrency as `Op`, and event pipelines as
24
+ `Stream`, the library disentangles business logic from control mechanics.
23
25
 
24
26
  To support these patterns without introducing bloat, the library is designed to be lightweight,
25
27
  zero-dependency, and fully tree-shakeable. The core module (`/core`) is under 16 KB gzipped, and the
@@ -30,7 +32,11 @@ environments.
30
32
 
31
33
  Full guides and API reference at **[pipelined.lozgachev.dev](https://pipelined.lozgachev.dev)**.
32
34
 
33
- ## Example: composing optional values
35
+ ---
36
+
37
+ ## Examples
38
+
39
+ ### Composing optional values
34
40
 
35
41
  `null` checks accumulate fast. Each one is a conditional branch that the type system can't help you
36
42
  forget. `Maybe<A>` turns absence into a value that composes — the same operations apply whether or
@@ -45,7 +51,7 @@ const parseDiscount = (raw: string): string =>
45
51
  pipe(
46
52
  raw,
47
53
  Str.trim,
48
- Num.parse, // "10" → Some(10), "abc" → None
54
+ Num.parse, // "15" → Some(15), "abc" → None
49
55
  Maybe.filter((n) => n >= 0 && n <= 100), // out of range → None
50
56
  Maybe.map((n) => `${n}% off`),
51
57
  Maybe.getOrElse(() => "No discount"),
@@ -58,7 +64,7 @@ parseDiscount("abc"); // "No discount"
58
64
 
59
65
  Every step that sees `None` is skipped. The fallback runs once, at the end.
60
66
 
61
- ## Example: typed async errors
67
+ ### Typed async errors and cancellation
62
68
 
63
69
  In JavaScript, asynchronous exceptions bypass the static type system, leaving unhandled rejections
64
70
  as invisible runtime risks. `Task.Result<E, A>` represents fallible asynchronous computations as
@@ -69,6 +75,8 @@ function signature, ensuring that failures are handled before compile time:
69
75
  import { pipe } from "@nlozgachev/pipelined/composition";
70
76
  import { Result, Task } from "@nlozgachev/pipelined/core";
71
77
 
78
+ type User = { id: string; name: string };
79
+ type Post = { id: string; title: string };
72
80
  type ApiError = { status: number; message: string };
73
81
 
74
82
  const fetchUser = (id: string): Task.Result<ApiError, User> =>
@@ -78,14 +86,14 @@ const fetchUser = (id: string): Task.Result<ApiError, User> =>
78
86
  if (!r.ok) throw { status: r.status, message: r.statusText };
79
87
  return r.json() as Promise<User>;
80
88
  }),
81
- (e) => e as ApiError,
89
+ { onError: (e) => e as ApiError },
82
90
  );
83
91
 
84
92
  const fetchPosts = (userId: string): Task.Result<ApiError, Post[]> =>
85
93
  Task.Result.tryCatch(
86
94
  (signal) =>
87
95
  fetch(`/users/${userId}/posts`, { signal }).then((r) => r.json()),
88
- (e) => e as ApiError,
96
+ { onError: (e) => e as ApiError },
89
97
  );
90
98
 
91
99
  // Chain two requests — the AbortSignal propagates to both automatically
@@ -116,7 +124,7 @@ if (Result.is.ok(result)) {
116
124
  }
117
125
  ```
118
126
 
119
- ## Example: transforming data
127
+ ### Transforming data collections
120
128
 
121
129
  Standard JavaScript arrays and records routinely return `undefined` on out-of-bounds access or
122
130
  missing keys. The utility modules in `pipelined` wrap these operations with data-last, curried
@@ -156,7 +164,83 @@ replaces a `map` followed by a `filter`. `Arr.head` returns `Maybe<Item>` rather
156
164
  `Item | undefined`, so the absence is explicit in the type and the rest of the pipeline handles it
157
165
  the same way.
158
166
 
159
- ## Example: retry, timeout, and cancellation
167
+ ### Multi-field form validation
168
+
169
+ In web forms and batch data ingestion, failing fast on the first error produces poor user
170
+ experiences. `Validation<E, A>` accumulates all errors across independent fields simultaneously:
171
+
172
+ ```ts
173
+ import { pipe } from "@nlozgachev/pipelined/composition";
174
+ import { Validation } from "@nlozgachev/pipelined/core";
175
+ import { Str } from "@nlozgachev/pipelined/data";
176
+
177
+ type SignupForm = { username: string; email: string };
178
+
179
+ const validateUsername = (name: string): Validation<string, string> =>
180
+ pipe(
181
+ name,
182
+ Str.trim,
183
+ Validation.from.Predicate(
184
+ (s) => s.length >= 3,
185
+ () => "Username must be at least 3 characters",
186
+ ),
187
+ );
188
+
189
+ const validateEmail = (email: string): Validation<string, string> =>
190
+ pipe(
191
+ email,
192
+ Str.trim,
193
+ Validation.from.Predicate(
194
+ (s) => s.includes("@"),
195
+ () => "Email must contain an @ symbol",
196
+ ),
197
+ );
198
+
199
+ const validateSignup = (form: SignupForm) =>
200
+ Validation.struct({
201
+ username: validateUsername(form.username),
202
+ email: validateEmail(form.email),
203
+ });
204
+
205
+ const outcome = validateSignup({ username: "a", email: "invalid" });
206
+
207
+ if (Validation.is.failed(outcome)) {
208
+ console.log(outcome.errors);
209
+ // ["Username must be at least 3 characters", "Email must contain an @ symbol"]
210
+ }
211
+ ```
212
+
213
+ ### Eliminating impossible UI states
214
+
215
+ Managing asynchronous data in UI components with separate boolean flags (`isLoading`, `isError`,
216
+ `data`) leads to contradictory combinations (like showing a spinner alongside an error alert).
217
+ `RemoteData` models the complete 4-state lifecycle explicitly:
218
+
219
+ ```ts
220
+ import { pipe } from "@nlozgachev/pipelined/composition";
221
+ import { RemoteData } from "@nlozgachev/pipelined/core";
222
+
223
+ type UserProfile = { name: string };
224
+ type FetchError = { message: string };
225
+
226
+ const renderUI = (state: RemoteData<FetchError, UserProfile>): string =>
227
+ pipe(
228
+ state,
229
+ RemoteData.match({
230
+ notAsked: () => "Click to load profile",
231
+ loading: () => "Loading...",
232
+ failure: (err) => `Failed: ${err.message}`,
233
+ success: (user) => `Welcome, ${user.name}!`,
234
+ }),
235
+ );
236
+
237
+ renderUI(RemoteData.make.notAsked()); // "Click to load profile"
238
+ renderUI(RemoteData.make.loading()); // "Loading..."
239
+ renderUI(RemoteData.make.failure({ message: "Network down" })); // "Failed: Network down"
240
+ renderUI(RemoteData.make.success({ name: "Alice" })); // "Welcome, Alice!"
241
+ ```
242
+
243
+ ### Managing request lifecycles, retries, and timeouts
160
244
 
161
245
  Handling robust network interactions — including retry attempts, backoff timing, timeouts, and
162
246
  signal-driven cancellation — typically requires complex, stateful code that is highly prone to
@@ -236,13 +320,11 @@ if (Op.isOk(outcome)) {
236
320
  fetchUser.abort();
237
321
  ```
238
322
 
239
- ## Example: repeated UI interactions
323
+ ### Repeated UI interactions and concurrency strategies
240
324
 
241
325
  User interfaces frequently trigger repeated asynchronous events: a search input firing on every
242
- keystroke, a submit button clicked multiple times, or a polling loop that must terminate when a
243
- newer request starts. Managing these concurrency scenarios traditionally requires complex, ad-hoc
244
- state machines. `Op` simplifies this by allowing developers to declare the concurrency strategy as a
245
- simple configuration choice:
326
+ keystroke, a submit button clicked multiple times, or background draft auto-saving. `Op` lets you
327
+ declare the concurrency strategy as a clean configuration choice:
246
328
 
247
329
  **Search — cancel the previous call when the user types:**
248
330
 
@@ -259,7 +341,7 @@ const searchOp = Op.create(
259
341
  );
260
342
 
261
343
  const search = Op.interpret(searchOp, {
262
- strategy: "restartable", // new call cancels the previous one
344
+ strategy: "restartable", // new call cancels the previous in-flight request
263
345
  retry: { attempts: 2, backoff: Duration.milliseconds(300) },
264
346
  });
265
347
 
@@ -273,7 +355,7 @@ search.subscribe((state) => {
273
355
  input.addEventListener("input", (e) => search.run(e.currentTarget.value));
274
356
  ```
275
357
 
276
- **Form submit — drop concurrent submissions:**
358
+ **Form submit — drop concurrent duplicate submissions:**
277
359
 
278
360
  ```ts
279
361
  const submitOp = Op.create(
@@ -301,14 +383,13 @@ form.addEventListener("submit", (e) => {
301
383
  ```
302
384
 
303
385
  The system supports a variety of built-in strategies — `restartable`, `exclusive`, `debounced`,
304
- `throttled`, `queue`, `buffered`, `concurrent`, `keyed`, and `once` — making the integration of
305
- complex async scenarios highly predictable.
386
+ `throttled`, `queue`, `buffered`, `concurrent`, `keyed`, and `once`.
306
387
 
307
- ## Example: typed event streaming and sequence reduction
388
+ ### Event streaming, sequence funnels, and queue safety
308
389
 
309
390
  Decoupling event producers from stateful event consumers often leads to untyped event emitters or
310
- complex ad-hoc state machines. `Stream` models event pipelines with typed message schemas, sequence
311
- pattern matching, and state reduction:
391
+ recursive call stack crashes during event cascades. `Stream` models in-memory event pipelines with
392
+ typed message schemas, multi-step sequence pattern matching, and causal FIFO queue dispatching:
312
393
 
313
394
  ```ts
314
395
  import { Stream } from "@nlozgachev/pipelined/core";
@@ -317,15 +398,16 @@ type UserFlowMessages = {
317
398
  sessionStarted: { sessionId: string };
318
399
  stepCompleted: { stepName: string };
319
400
  flowFinished: { totalTimeMs: number };
401
+ flowCancelled: { reason: string };
320
402
  };
321
403
 
322
404
  const flowStream = Stream.make<UserFlowMessages>();
323
405
 
324
- // Match sequence: sessionStarted -> stepCompleted -> flowFinished
406
+ // Pattern-match the complete multi-step funnel
325
407
  const sub = Stream.listen(
326
408
  flowStream,
327
409
  ["sessionStarted", "stepCompleted", "flowFinished"],
328
- { ordered: true },
410
+ { ordered: true, reset: "flowCancelled" },
329
411
  ).reduce(
330
412
  (msg, state) => {
331
413
  if (msg.kind === "flowFinished") {
@@ -355,62 +437,135 @@ Stream.emit(flowStream, {
355
437
  sub.getState(); // { completedFlows: 1 }
356
438
  ```
357
439
 
358
- ## What is included
440
+ `Stream` executes cascading events iteratively using an internal queue with O(1) stack overhead,
441
+ completely preventing stack overflow crashes and out-of-order re-entrant execution.
359
442
 
360
- The library covers the full spectrum of state and control flow scenarios encountered in production
361
- applications.
443
+ ### Deep immutable updates without spread boilerplate
362
444
 
363
- ### Core context containers
445
+ Modifying deeply nested properties in state trees normally requires tedious spread syntax. `Lens`
446
+ and `Optional` turn nested paths into first-class values:
364
447
 
365
- `Maybe` represents explicit optionality without null checks. `Result` handles synchronous, typed
366
- success and failure, while `Validation` accumulates multiple errors. `RemoteData` tracks the four
367
- states of an asynchronous data fetch (`NotAsked`, `Loading`, `Failure`, `Success`), `These` handles
368
- inclusive-OR scenarios containing a first value, a second, or both simultaneously, and `Tuple`
369
- provides a strongly-typed, immutable two-element pair.
448
+ ```ts
449
+ import { pipe } from "@nlozgachev/pipelined/composition";
450
+ import { Lens } from "@nlozgachev/pipelined/core";
370
451
 
371
- ### Asynchronous operations
452
+ type Address = { city: string; zip: string };
453
+ type User = { name: string; address: Address };
372
454
 
373
- `Task` represents a lazy, infallible asynchronous computation. Fallible asynchronous workflows are
374
- handled by `Task.Result`, `Task.Maybe`, and `Task.Validation`. For managing stateful, recurring
375
- asynchronous operations with complex scheduling, `Op` implements named concurrency strategies such
376
- as `restartable`, `exclusive`, `debounced`, `throttled`, and `queue`, handling retries, timeouts,
377
- and signal propagation automatically. `Stream` provides typed event streaming, sequence pattern
378
- matching, state reduction, and structural forwarding across channels. `Deferred` represents a
379
- lightweight, infallible asynchronous value that is guaranteed to always resolve without rejection.
455
+ const user: User = {
456
+ name: "Alice",
457
+ address: { city: "Berlin", zip: "10115" },
458
+ };
380
459
 
381
- ### Optics and environment state
460
+ const addressLens = Lens.from.property<User>()("address");
461
+ const cityLens = Lens.from.property<Address>()("city");
462
+ const userCityLens = pipe(addressLens, Lens.andThen(cityLens));
382
463
 
383
- `Lens` and `Optional` provide a simple concrete interface for safe, nested immutable data updates.
384
- Environment-dependent calculations and explicit state threading are supported by the `Reader` and
385
- `State` abstractions, while `Logged` enables side-effect-free data logging, and `Lazy` implements
386
- synchronous memoized thunks.
464
+ // Immutably modify deep property in one line
465
+ const updatedUser = pipe(
466
+ user,
467
+ Lens.modify(userCityLens)((c) => c.toUpperCase()),
468
+ );
387
469
 
388
- ### Algebraic and logic abstractions
470
+ updatedUser.address.city; // "BERLIN"
471
+ ```
389
472
 
390
- For general type-safe comparisons and algebraic operations, the library includes `Equality` for
391
- composable object and primitive comparisons, `Ordering` for sorting comparators, and `Combinable`
392
- for structural monoids (allowing folding collections with a neutral starting point). Composable
393
- boolean checks and type guards are supported by `Predicate` and `Refinement` abstractions.
473
+ ### Nominal type safety and security boundaries
394
474
 
395
- ### Optimized utilities
475
+ TypeScript's structural typing allows accidental parameter swapping (such as passing an `OrderId`
476
+ into a `UserId` slot). `Brand` creates nominal types with smart constructor validation gates at zero
477
+ runtime cost:
396
478
 
397
- Custom, performance-optimized utility modules (`Arr`, `Rec`, `Dict`, `Uniq`, `Num`, `Str`) wrap
398
- standard JavaScript types to return explicit types like `Maybe` and support data-last currying.
399
- Functions are composed using `pipe` and `flow`, which are enriched with high-level composition
400
- helpers like `when`, `unless`, `either`, `safe`, and `async` to support robust, expressive
401
- pipelines.
479
+ ```ts
480
+ import { pipe } from "@nlozgachev/pipelined/composition";
481
+ import { Result } from "@nlozgachev/pipelined/core";
482
+ import { Brand } from "@nlozgachev/pipelined/types";
402
483
 
403
- ### Nominal branding, durations, and non-empty collections
484
+ type UserId = Brand<"UserId", string>;
485
+ type OrderId = Brand<"OrderId", string>;
486
+ type Email = Brand<"Email", string>;
404
487
 
405
- Compile-time nominal typing with zero runtime overhead is provided by `Brand`. `Duration` safely
406
- models and converts time durations (seconds, milliseconds, etc.). `Arr.NonEmpty` and `Rec.NonEmpty`
407
- guarantee that an array or record is never empty, eliminating defensive length/emptiness checks at
408
- runtime.
488
+ const parseEmail = (raw: string): Result<string, Email> =>
489
+ raw.includes("@")
490
+ ? Result.make.ok(raw as Email)
491
+ : Result.make.err("Invalid email address");
492
+
493
+ const sendReceipt = (userId: UserId, orderId: OrderId, email: Email) =>
494
+ `Sent to ${email} for order ${orderId} by user ${userId}`;
495
+
496
+ const user = "usr_100" as UserId;
497
+ const order = "ord_500" as OrderId;
498
+ const email = parseEmail("alice@example.com");
499
+
500
+ if (Result.is.ok(email)) {
501
+ sendReceipt(user, order, email.value);
502
+ // Type checker prevents: sendReceipt(order, user, email.value)
503
+ }
504
+ ```
409
505
 
410
- Every utility in the library is benchmarked against its native equivalent. The data-last currying
411
- adds a small function call overhead, which is the expected cost of composability. For operations
412
- where native overhead is significant, custom implementations are used that often run faster than
413
- their native counterparts.
506
+ ---
507
+
508
+ ## Quick Reference
509
+
510
+ | Problem to Solve | Module | Import Path |
511
+ | ------------------------------------------------------------- | ----------------------------- | ----------------------------------- |
512
+ | Optional values without `null` / `undefined` checks | `Maybe` | `@nlozgachev/pipelined/core` |
513
+ | Synchronous typed errors without `try`/`catch` | `Result` | `@nlozgachev/pipelined/core` |
514
+ | Multi-field form and batch validation error accumulation | `Validation` | `@nlozgachev/pipelined/core` |
515
+ | Lazy async workflows with automatic `AbortSignal` | `Task.Result`, `Task` | `@nlozgachev/pipelined/core` |
516
+ | Infallible async return container for tasks | `Deferred` | `@nlozgachev/pipelined/core` |
517
+ | Eliminating impossible UI loading/error/data states | `RemoteData` | `@nlozgachev/pipelined/core` |
518
+ | Managing request race conditions, retries, and locks | `Op` | `@nlozgachev/pipelined/core` |
519
+ | In-memory event streaming, sequence funnels & queue safety | `Stream` | `@nlozgachev/pipelined/core` |
520
+ | Deep nested immutable updates without spread boilerplate | `Lens`, `Optional` | `@nlozgachev/pipelined/core` |
521
+ | Implicit dependency injection without prop drilling | `Reader` | `@nlozgachev/pipelined/core` |
522
+ | Pure state transitions, tokenizers, and parsers | `State` | `@nlozgachev/pipelined/core` |
523
+ | Deterministic bracket cleanup (DB pools, file locks) | `Resource` | `@nlozgachev/pipelined/core` |
524
+ | Deferred computation memoized on first access | `Lazy` | `@nlozgachev/pipelined/core` |
525
+ | Pure calculation audit trails and decision logging | `Logged` | `@nlozgachev/pipelined/core` |
526
+ | Inclusive-OR data modeling and two-way sync diffs | `These` | `@nlozgachev/pipelined/core` |
527
+ | Deep structural equality & React component memoization | `Equality` | `@nlozgachev/pipelined/core` |
528
+ | Multi-column table sorting with tiebreakers | `Ordering` | `@nlozgachev/pipelined/core` |
529
+ | Composable boolean filter pipelines & authorization policies | `Predicate` | `@nlozgachev/pipelined/core` |
530
+ | Runtime type narrowing & custom type guard composition | `Refinement` | `@nlozgachev/pipelined/core` |
531
+ | Merging configurations & metric structures (Monoids) | `Combinable` | `@nlozgachev/pipelined/core` |
532
+ | Strongly-typed immutable pair manipulation | `Tuple` | `@nlozgachev/pipelined/core` |
533
+ | Point-free, bounds-safe array transformations | `Arr`, `Arr.NonEmpty` | `@nlozgachev/pipelined/data` |
534
+ | Type-safe object manipulation & key migration | `Rec`, `Rec.NonEmpty` | `@nlozgachev/pipelined/data` |
535
+ | Insertion-ordered maps with non-string keys | `Dict`, `Dict.NonEmpty` | `@nlozgachev/pipelined/data` |
536
+ | Immutable sets & role/permission algebra | `Uniq` | `@nlozgachev/pipelined/data` |
537
+ | String sanitization, numeric conversion & slug parsing | `Str` | `@nlozgachev/pipelined/data` |
538
+ | Boundary clamping & division-by-zero protection | `Num` | `@nlozgachev/pipelined/data` |
539
+ | Financial ledger arithmetic without float precision drift | `BigNum` | `@nlozgachev/pipelined/data` |
540
+ | Safe JSON parsing & circular reference protection | `Json` | `@nlozgachev/pipelined/data` |
541
+ | Nominal typing & security boundary gates | `Brand` | `@nlozgachev/pipelined/types` |
542
+ | Explicit, unit-safe time spans & timeout policies | `Duration`, `RetryPolicy` | `@nlozgachev/pipelined/types` |
543
+ | Left-to-right value pipeline execution | `pipe` | `@nlozgachev/pipelined/composition` |
544
+ | Left-to-right and right-to-left function composition | `flow`, `compose` | `@nlozgachev/pipelined/composition` |
545
+ | Currying, uncurrying, and argument flipping | `curry`, `uncurry`, `flip` | `@nlozgachev/pipelined/composition` |
546
+ | Multi-branch argument routing and combining | `converge`, `juxt`, `on` | `@nlozgachev/pipelined/composition` |
547
+ | Pure function memoization, predicates & pipeline side-effects | `memoize`, `tap`, `not`, `fn` | `@nlozgachev/pipelined/composition` |
548
+
549
+ ---
550
+
551
+ ## Package Architecture and Performance
552
+
553
+ `pipelined` is structured into 4 isolated, tree-shakeable entry points:
554
+
555
+ - **`@nlozgachev/pipelined/core`**: Core context containers, async runtimes, optics, and logic
556
+ abstractions (<16 KB gzipped).
557
+ - **`@nlozgachev/pipelined/data`**: Curried, data-last utilities for collections, numbers, strings,
558
+ and JSON (<13 KB gzipped).
559
+ - **`@nlozgachev/pipelined/composition`**: Pure higher-order function combinators (`pipe`, `flow`,
560
+ `compose`, `curry`, `uncurry`, `converge`, `juxt`, `memoize`, `tap`, `on`, `not`, `flip`, `fn`)
561
+ (<4 KB gzipped).
562
+ - **`@nlozgachev/pipelined/types`**: Type-level utilities (`Brand`, `Duration`, `RetryPolicy`) (<2
563
+ KB gzipped).
564
+
565
+ Every utility in the library is benchmarked against its native equivalent. While currying introduces
566
+ a small function call overhead for composability, the library uses custom algorithms for
567
+ data-structure methods whenever the native JavaScript implementations are slower, ensuring the
568
+ fastest execution path possible.
414
569
 
415
570
  ## License
416
571
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nlozgachev/pipelined",
3
- "version": "0.50.0",
3
+ "version": "0.52.0",
4
4
  "description": "Opinionated functional abstractions for TypeScript",
5
5
  "license": "BSD-3-Clause",
6
6
  "homepage": "https://pipelined.lozgachev.dev",
@@ -55,6 +55,7 @@
55
55
  "scripts": {
56
56
  "build": "tsup",
57
57
  "test": "vitest run",
58
+ "test:coverage": "vitest run --coverage",
58
59
  "bench": "vitest bench",
59
60
  "size": "size-limit",
60
61
  "typecheck": "tsc --noEmit",