@railway-ts/pipelines 0.1.2 → 0.1.4

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 +125 -752
  2. package/package.json +17 -13
package/README.md CHANGED
@@ -2,523 +2,126 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@railway-ts/pipelines.svg)](https://www.npmjs.com/package/@railway-ts/pipelines) [![Build Status](https://github.com/sakobu/railway-ts-pipelines/workflows/CI/badge.svg)](https://github.com/sakobu/railway-ts-pipelines/actions) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/@railway-ts/pipelines)](https://bundlephobia.com/package/@railway-ts/pipelines) [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue)](https://www.typescriptlang.org/) [![Coverage](https://img.shields.io/codecov/c/github/sakobu/railway-ts-pipelines)](https://codecov.io/gh/sakobu/railway-ts-pipelines)
4
4
 
5
- **Make failure boring. Make data flow.**
5
+ Railway-oriented programming for TypeScript. Result and Option types that don't suck.
6
6
 
7
- A type-safe toolkit for TypeScript implementing railway-oriented programming. Build robust data pipelines with zero classes, zero exceptions, and zero `any`. Model uncertainty with `Option` and `Result`, validate once at boundaries, and let errors flow naturally through your code.
7
+ **Design philosophy:** Small, focused API surface. Practical over academic. No fp-ts complexity, no Effect-TS kitchen sink.
8
8
 
9
- ---
10
-
11
- ## Table of Contents
12
-
13
- ### Getting Started
14
-
15
- - [The Problem: Error Handling is Messy](#the-problem-error-handling-is-messy)
16
- - [The Solution: Railway-Oriented Programming](#the-solution-railway-oriented-programming)
17
- - [Installation](#installation)
18
- - [Running the Examples](#running-the-examples)
19
-
20
- ### Core Concepts
21
-
22
- - [The Building Blocks](#the-building-blocks)
23
- - [Option: Handle Absence as Data](#option-handle-absence-as-data)
24
- - [Result: Railway-Oriented Error Handling](#result-railway-oriented-error-handling)
25
- - [Schema: Parse, Don't Validate](#schema-parse-dont-validate)
26
- - [Composition: Build Complex Pipelines](#composition-build-complex-pipelines)
27
-
28
- ### Practical Guides
29
-
30
- - [Import Strategy](#import-strategy)
31
- - [Quick Reference](#quick-reference)
32
-
33
- ### Decision Guides
34
-
35
- - [Is This Right For You?](#is-this-right-for-you)
36
-
37
- ### Reference
38
-
39
- - [Next Steps](#next-steps)
40
- - [Further Reading](#further-reading)
41
-
42
- ---
43
-
44
- ## The Problem: Error Handling is Messy
45
-
46
- Most TypeScript codebases struggle with error handling. Sound familiar?
47
-
48
- ```typescript
49
- // Traditional approach: nested try/catch, type assertions, repeated checks
50
- function processOrder(raw: unknown) {
51
- try {
52
- if (!raw || typeof raw !== 'object') throw new Error('Invalid input');
53
- const amount = (raw as any).amount;
54
- if (typeof amount !== 'number' || amount <= 0) throw new Error('Invalid amount');
55
- const result = await chargeCard(amount);
56
- if (!result.success) throw new Error(result.error);
57
- return { success: true, data: result };
58
- } catch (e) {
59
- return { success: false, error: String(e) };
60
- }
61
- }
62
- ```
63
-
64
- **Problems:**
65
-
66
- - Exception-based control flow escapes your types
67
- - Type assertions (`as any`) break type safety
68
- - Repeated validation adds ceremony
69
- - Nested conditionals obscure the happy path
70
-
71
- **What if there was a better way?**
72
-
73
- ```typescript
74
- // Railway-oriented: validate once, compose pure functions, branch at edges
75
- const orderSchema = object({
76
- amount: required(chain(number(), min(0.01))),
77
- });
78
-
79
- const processOrder = flow(
80
- (input: unknown) => validate(input, orderSchema),
81
- (r) => andThen(r, ({ amount }) => chargeCard(amount)),
82
- );
83
- // Errors automatically stay on the error track. No nested conditionals.
84
- ```
85
-
86
- ---
87
-
88
- ## The Solution: Railway-Oriented Programming
89
-
90
- **Railway-oriented programming** treats your code like a railway track with two parallel rails:
91
-
92
- ```
93
- Input --> validate --> transform --> compute --> Output
94
- | |
95
- | (validation error) |
96
- +-------> Error Track ----------------+
97
- (auto-propagates)
98
- ```
99
-
100
- ### How It Works
101
-
102
- 1. **Valid data** stays on the success track
103
- 2. **Errors** automatically switch to the error track
104
- 3. **No manual checking** - transformations skip when already on error track
105
- 4. **Branch once** at the very end with pattern matching
106
-
107
- ### Core Philosophy
108
-
109
- This library brings this pattern to TypeScript with three principles:
110
-
111
- 1. **Errors and Absence are Values** - Not exceptions. Use `Result<T, E>` for operations that can fail, `Option<T>` for optional values
112
- 2. **Parse, Don't Validate** - Don't just check data; transform it into guaranteed-valid types
113
- 3. **Compose Everything** - Build complex workflows by piping simple functions
114
-
115
- ### The Pipeline Mental Model
116
-
117
- ```
118
- unknown input -> validate -> Result<T, E> -> transform -> compute -> Result<U, E>
119
- | | |
120
- boundary type-safe stays on rails
121
- ```
122
-
123
- - **Boundary**: Untrusted data enters as `unknown`
124
- - **Validate**: Convert to `Result<T, E>` using schema validators
125
- - **Type-Safe Core**: Transform validated data with pure functions
126
- - **Stay On Rails**: Errors propagate automatically through `map`/`flatMap`
127
- - **Branch Once**: Use `match()` at the edges to handle both paths
128
-
129
- ---
130
-
131
- ## Installation
9
+ ## Install
132
10
 
133
11
  ```bash
134
- bun add @railway-ts/pipelines
135
- # or npm install @railway-ts/pipelines
12
+ bun add @railway-ts/pipelines # or npm, pnpm, yarn
136
13
  ```
137
14
 
138
- **New to the library?** Check out the **[Getting Started Guide](GETTING_STARTED.md)** for a step-by-step tutorial with complete examples.
139
-
140
- ---
141
-
142
- ## Running the Examples
15
+ Requires TypeScript 5.0+ and Node.js 18+.
143
16
 
144
- The library includes 12+ runnable examples covering Option, Result, Schema validation, Composition patterns, and complete real-world pipelines.
145
-
146
- ```bash
147
- # Clone and explore
148
- git clone https://github.com/sakobu/railway-ts-pipelines.git
149
- cd railway-ts-pipelines
150
- bun install
151
-
152
- # Run all examples
153
- bun run examples/index.ts
154
-
155
- # Or run specific categories
156
- bun run examples/complete-pipelines/async-launch.ts # Rocket launch decision system
157
- ```
158
-
159
- **See the full guide**: **[Running the Examples ->](GETTING_STARTED.md#running-the-examples)** with all commands and what each demonstrates.
160
-
161
- ---
162
-
163
- ## The Building Blocks
164
-
165
- ### Option: Handle Absence as Data
166
-
167
- Replace `null`, `undefined`, and nullable types with explicit `Option<T>`.
168
-
169
- #### Core Type
17
+ ## Quick Start
170
18
 
171
19
  ```typescript
172
- type Option<T> = { readonly some: true; readonly value: T } | { readonly some: false };
173
- ```
174
-
175
- #### When to Use
176
-
177
- Use `Option` when **absence is expected and normal**: finding items in collections, optional configuration, nullable database fields. Don't use it when you need to carry error information (use `Result` instead).
178
-
179
- #### Basic Usage
180
-
181
- ```typescript
182
- import { some, none, fromNullable, map, unwrapOr, match } from '@railway-ts/pipelines/option';
183
-
184
- // Create Options
185
- const hasValue = some(42);
186
- const noValue = none<number>();
187
-
188
- // Convert from nullable
189
- const user: { email?: string } = getUser();
190
- const email = pipe(
191
- fromNullable(user.email),
192
- (opt) => map(opt, (e) => e.toLowerCase()),
193
- (opt) => unwrapOr(opt, 'no-email@example.com'),
194
- );
195
-
196
- // Pattern matching
197
- match(fromNullable(user.email), {
198
- some: (email) => sendWelcome(email),
199
- none: () => console.log('No email provided'),
200
- });
201
- ```
202
-
203
- #### API Overview
204
-
205
- | Category | Functions |
206
- | ------------- | ------------------------------------------------------------------------- |
207
- | **Create** | `some(value)`, `none()` |
208
- | **Check** | `isSome(option)`, `isNone(option)` |
209
- | **Transform** | `map(option, fn)`, `flatMap(option, fn)`, `filter(option, pred)` |
210
- | **Extract** | `unwrap(option)`, `unwrapOr(option, default)`, `unwrapOrElse(option, fn)` |
211
- | **Combine** | `combine(options)` - tuple-preserving, returns `none` if any is `none` |
212
- | **Convert** | `fromNullable(value)`, `mapToResult(option, error)` |
213
- | **Match** | `match(option, { some, none })` |
214
-
215
- **See full documentation**: [`src/option/option.ts`](src/option/option.ts) (21 functions)
216
- **See examples**: [`examples/option/option-examples.ts`](examples/option/option-examples.ts)
217
-
218
- ---
219
-
220
- ### Result: Railway-Oriented Error Handling
221
-
222
- Model success and failure explicitly. Compose operations without exceptions.
223
-
224
- #### Core Type
225
-
226
- ```typescript
227
- type Result<T, E> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: E };
228
- ```
229
-
230
- #### When to Use
231
-
232
- Use `Result` when **operations can fail and you need error context**: parsing data, I/O operations, business logic validations. You need to explain _why_ something failed, not just that it did.
233
-
234
- #### Basic Usage
235
-
236
- ```typescript
237
- import { ok, err, map, flatMap, match, fromTry } from '@railway-ts/pipelines/result';
238
20
  import { pipe } from '@railway-ts/pipelines/composition';
239
-
240
- // Operations that can fail return Result
241
- const safeDivide = (a: number, b: number): Result<number, string> => (b === 0 ? err('Division by zero') : ok(a / b));
242
-
243
- // Chain operations on the happy path
244
- const process = (input: string) =>
245
- pipe(
246
- fromTry(() => JSON.parse(input)),
247
- (r) => flatMap(r, (data) => safeDivide(data.value, 2)),
248
- (r) => map(r, Math.round),
249
- );
250
-
251
- // Branch once at the end
252
- match(process('{"value":42}'), {
253
- ok: (n) => console.log('Result', n),
254
- err: (e) => console.error('Failed', e),
255
- });
256
- ```
257
-
258
- #### API Overview
259
-
260
- | Category | Functions |
261
- | ---------------- | ------------------------------------------------------------------------------------------------- |
262
- | **Create** | `ok(value)`, `err(error)` |
263
- | **Check** | `isOk(result)`, `isErr(result)` |
264
- | **Transform** | `map(result, fn)`, `mapErr(result, fn)`, `flatMap(result, fn)` |
265
- | **Extract** | `unwrap(result)`, `unwrapOr(result, default)`, `unwrapOrElse(result, fn)` |
266
- | **Side Effects** | `tap(result, fn)`, `tapErr(result, fn)` |
267
- | **Combine** | `combine(results)` - short-circuits on first error<br>`combineAll(results)` - collects all errors |
268
- | **Async** | `fromPromise(promise)`, `toPromise(result)`, `andThen(result, asyncFn)` |
269
- | **Convert** | `fromTry(fn)`, `mapToOption(result)` |
270
- | **Match** | `match(result, { ok, err })` |
271
-
272
- **Critical for async pipelines**: Use `andThen(result, asyncFn)` to chain async operations while keeping them on the railway.
273
-
274
- **See full documentation**: [`src/result/result.ts`](src/result/result.ts) (27 functions)
275
- **See examples**: [`examples/result/result-examples.ts`](examples/result/result-examples.ts)
276
-
277
- ---
278
-
279
- ### Schema: Parse, Don't Validate
280
-
281
- Transform untrusted data into guaranteed-valid types. Validate once at boundaries, then work with confidence.
282
-
283
- #### Philosophy: Validation as Transformation
284
-
285
- Traditional validation checks data but leaves it as `unknown` or `any`. Railway-ts validators **parse and transform**:
286
-
287
- ```typescript
288
- // BAD: Traditional validation without transformation
289
- function process(input: any) {
290
- if (!input.age || typeof input.age !== 'number') throw new Error();
291
- if (input.age < 18) throw new Error();
292
- return doSomething(input.age); // input is still 'any'
293
- }
294
-
295
- // GOOD: Railway-ts parse into guaranteed-valid types
296
- const ageValidator = chain(parseNumber(), min(18));
297
- // Validator<unknown, number> - transforms unknown -> number
298
-
299
- const result = validate(input.age, ageValidator);
300
- // result: Result<number, ValidationError[]>
301
- // If ok, TypeScript knows it's a number >= 18
302
- ```
303
-
304
- #### Core Types
305
-
306
- ```typescript
307
- type Validator<Input, Output = Input> = (value: Input, path?: string[]) => Result<Output, ValidationError[]>;
308
-
309
- type ValidationError = {
310
- path: string[]; // e.g., ['user', 'address', 'zipCode']
311
- message: string;
312
- };
313
-
314
- // Extract output type from validator
315
- type InferSchemaType<V> = V extends Validator<unknown, infer O> ? ProcessType<O> : never;
316
- ```
317
-
318
- #### Basic Validators
319
-
320
- ```typescript
21
+ import { ok, match, andThen } from '@railway-ts/pipelines/result';
321
22
  import {
322
23
  validate,
323
- string,
324
- number,
325
- boolean,
326
- date,
327
24
  object,
328
25
  required,
329
- optional,
330
- nullable,
331
26
  chain,
332
- type InferSchemaType,
333
- } from '@railway-ts/pipelines/schema';
334
-
335
- // Primitive validators
336
- const name = string(); // unknown -> Result<string, Error[]>
337
- const age = number(); // unknown -> Result<number, Error[]>
338
- const active = boolean(); // unknown -> Result<boolean, Error[]>
339
-
340
- // Object schema
341
- const userSchema = object({
342
- name: required(string()), // must exist
343
- email: optional(string()), // may be undefined
344
- age: nullable(number()), // may be null
345
- });
346
-
347
- type User = InferSchemaType<typeof userSchema>;
348
- // { name: string; email?: string; age: number | null }
349
- ```
350
-
351
- #### Chaining Validators
352
-
353
- Use `chain()` to build validation pipelines:
354
-
355
- ```typescript
356
- import { chain, parseNumber, min, max, integer } from '@railway-ts/pipelines/schema';
357
-
358
- // Sequential validation + transformation
359
- const adultAge = chain(
360
- parseNumber(), // unknown -> number (or error)
361
- integer(), // number -> number (or error if not integer)
362
- min(18), // number -> number (or error if < 18)
363
- max(120), // number -> number (or error if > 120)
364
- );
365
-
366
- validate('25', adultAge); // ok(25) - parsed and validated
367
- validate('15', adultAge); // err([{ path: [], message: "Must be at least 18" }])
368
- ```
369
-
370
- #### String, Number, Array Validators
371
-
372
- ```typescript
373
- import {
374
- string,
375
- minLength,
376
- maxLength,
377
- pattern,
378
- nonEmpty,
379
- email,
380
- number,
27
+ parseNumber,
381
28
  min,
382
- max,
383
- between,
384
- integer,
385
- positive,
386
- array,
387
- minItems,
388
- maxItems,
389
- unique,
390
- stringEnum,
29
+ formatErrors,
30
+ type ValidationError,
31
+ type ValidationResult,
391
32
  } from '@railway-ts/pipelines/schema';
392
33
 
393
- // String validation
394
- const username = chain(
395
- string(),
396
- nonEmpty('Username required'),
397
- minLength(3, 'Too short'),
398
- pattern(/^[a-zA-Z0-9_]+$/, 'Alphanumeric only'),
399
- );
34
+ const schema = object({
35
+ x: required(chain(parseNumber(), min(0))),
36
+ y: required(chain(parseNumber(), min(1))),
37
+ });
400
38
 
401
- // Number validation
402
- const price = chain(number(), positive('Price must be positive'), precision(2, 'Max 2 decimal places'));
39
+ async function compute(input: unknown): Promise<ValidationResult<number>> {
40
+ const result = await pipe(validate(input, schema), (r) => andThen(r, ({ x, y }) => ok(x / y)));
403
41
 
404
- // Array validation
405
- const tags = chain(
406
- array(string()),
407
- minItems(1, 'At least one tag required'),
408
- maxItems(10, 'Max 10 tags'),
409
- unique('Duplicate tags not allowed'),
410
- );
42
+ return match<number, ValidationError[], ValidationResult<number>>(result, {
43
+ ok: (value) => ({ valid: true, data: value }),
44
+ err: (errors) => ({ valid: false, errors: formatErrors(errors) }),
45
+ });
46
+ }
411
47
 
412
- // Enum validation
413
- const status = stringEnum(['pending', 'approved', 'rejected'] as const);
414
- // Returns: Validator<unknown, 'pending' | 'approved' | 'rejected'>
48
+ await compute({ x: 10, y: 2 }).then(console.log); // { valid: true, data: 5 }
415
49
  ```
416
50
 
417
- #### Parsing Validators (Type Transformations)
418
-
419
- These validators transform types during validation:
51
+ **The pattern:** Validate at boundaries, chain operations, branch once at the end. Errors propagate automatically.
420
52
 
421
- ```typescript
422
- import {
423
- parseNumber, // string -> number
424
- parseDate, // string -> Date
425
- parseJSON, // string -> unknown
426
- parseEnum, // string -> EnumValue
427
- } from '@railway-ts/pipelines/schema';
53
+ ## Documentation
428
54
 
429
- // Convert string to number in validation pipeline
430
- const ageFromString = chain(parseNumber('Invalid number'), min(18, 'Must be adult'));
55
+ **[Getting Started](GETTING_STARTED.md)** - Your first pipeline
56
+ **[Recipes](docs/RECIPES.md)** - Common patterns (point-free composition, async, validation)
57
+ → **[Advanced](docs/ADVANCED.md)** - Symbol branding, tuple preservation, type inference
58
+ → **[Examples](examples/)** - Working code you can run
431
59
 
432
- validate('25', ageFromString); // ok(25) - note: number, not string
60
+ ## Why This Library
433
61
 
434
- // Parse JSON and validate structure
435
- const jsonUserSchema = chain(
436
- parseJSON(),
437
- object({
438
- name: required(string()),
439
- age: required(number()),
440
- }),
441
- );
62
+ **Focused scope:** Result, Option, validation, composition. That's it. No monads seminar.
442
63
 
443
- validate('{"name":"Alice","age":30}', jsonUserSchema);
444
- // ok({ name: "Alice", age: 30 })
445
- ```
64
+ **Practical:** Eliminates boilerplate for real patterns. Documentation shows you how, doesn't make it part of the API.
446
65
 
447
- #### Union Types
66
+ **Type-safe:** Symbol branding prevents duck typing bugs. Tuple preservation means no type casts.
448
67
 
449
- ```typescript
450
- import { union, discriminatedUnion, literal } from '@railway-ts/pipelines/schema';
451
-
452
- // Try validators in order (first success wins)
453
- const stringOrNumber = union([string(), parseNumber()]);
454
-
455
- // Discriminated unions (more efficient)
456
- const shapeSchema = discriminatedUnion('type', {
457
- circle: object({
458
- type: required(literal('circle')),
459
- radius: required(number()),
460
- }),
461
- rectangle: object({
462
- type: required(literal('rectangle')),
463
- width: required(number()),
464
- height: required(number()),
465
- }),
466
- });
68
+ **Railway-oriented:** Errors propagate automatically. Write happy path code, handle errors once at the end.
467
69
 
468
- type Shape = InferSchemaType<typeof shapeSchema>;
469
- // { type: 'circle', radius: number } | { type: 'rectangle', width: number, height: number }
470
- ```
70
+ ## API Reference
471
71
 
472
- #### Tuple Validators
72
+ ### Option
473
73
 
474
- Validate fixed-length arrays with type-safe element validation.
74
+ Handle nullable values without `if (x != null)` everywhere.
475
75
 
476
76
  ```typescript
477
- import { tuple, tupleOf, chain, number, string, boolean, min, max, integer } from '@railway-ts/pipelines/schema';
478
-
479
- // Heterogeneous tuple - different types per position
480
- const userRecord = tuple([
481
- string(), // position 0: string
482
- chain(number(), integer(), min(0)), // position 1: non-negative integer
483
- boolean(), // position 2: boolean
484
- ]);
77
+ import { pipe } from '@railway-ts/pipelines/composition';
78
+ import { some, map, match } from '@railway-ts/pipelines/option';
485
79
 
486
- type UserRecord = InferSchemaType<typeof userRecord>;
487
- // [string, number, boolean]
80
+ const user = some({ name: 'Alice', age: 25 });
81
+ const name = pipe(user, (o) => map(o, (u) => u.name));
488
82
 
489
- validate(['user123', 25, true], userRecord); // ok(['user123', 25, true])
490
- validate(['user123', -5, true], userRecord); // err - age must be >= 0
83
+ match(name, {
84
+ some: (n) => console.log(n),
85
+ none: () => console.log('No user'),
86
+ }); // Output: Alice
87
+ ```
491
88
 
492
- // Homogeneous tuple - same type repeated N times
493
- const vector3 = tupleOf(number(), 3); // exactly 3 numbers
89
+ **Core:** `some`, `none`, `isSome`, `isNone`
90
+ **Transform:** `map`, `flatMap`, `bimap`, `filter`, `tap`
91
+ **Unwrap:** `unwrap`, `unwrapOr`, `unwrapOrElse`
92
+ **Combine:** `combine`
93
+ **Convert:** `fromNullable`, `mapToResult`
94
+ **Branch:** `match`
494
95
 
495
- type Vector3 = InferSchemaType<typeof vector3>;
496
- // [number, number, number]
96
+ ### Result
497
97
 
498
- validate([1.5, 2.3, 3.7], vector3); // ok([1.5, 2.3, 3.7])
499
- validate([1, 2], vector3); // err - expected length 3, got 2
98
+ Explicit error handling. No exceptions, no try-catch pyramids.
500
99
 
501
- // With constraints
502
- const rgbColor = tupleOf(
503
- chain(number(), integer(), min(0), max(255)),
504
- 3
505
- );
100
+ ```typescript
101
+ import { pipe } from '@railway-ts/pipelines/composition';
102
+ import { ok, err, map, match } from '@railway-ts/pipelines/result';
506
103
 
507
- validate([255, 128, 64], rgbColor); // ok([255, 128, 64])
508
- validate([255, 300, 0], rgbColor); // err - 300 exceeds max of 255
104
+ const divide = (a: number, b: number) => (b === 0 ? err('div by zero') : ok(a / b));
509
105
 
510
- // Geographic coordinates [latitude, longitude]
511
- const coordinate = tuple([
512
- chain(number(), min(-90), max(90)), // latitude
513
- chain(number(), min(-180), max(180)), // longitude
514
- ]);
106
+ const result = pipe(divide(10, 2), (r) => map(r, (x) => x * 3));
515
107
 
516
- validate([37.7749, -122.4194], coordinate); // ok - San Francisco
108
+ match(result, {
109
+ ok: (value) => console.log(value),
110
+ err: (error) => console.error(error),
111
+ }); // Output: 15
517
112
  ```
518
113
 
519
- **Use cases:** Coordinates, RGB colors, version numbers, matrix rows, vectors, any fixed-length structured data.
114
+ **Core:** `ok`, `err`, `isOk`, `isErr`
115
+ **Transform:** `map`, `mapErr`, `flatMap`, `bimap`, `filter`, `tap`, `tapErr`
116
+ **Unwrap:** `unwrap`, `unwrapOr`, `unwrapOrElse`
117
+ **Combine:** `combine`, `combineAll`
118
+ **Convert:** `fromTry`, `fromTryWithError`, `fromPromise`, `fromPromiseWithError`, `toPromise`, `mapToOption`
119
+ **Async:** `andThen`
120
+ **Branch:** `match`
520
121
 
521
- #### Complete Schema Example
122
+ ### Schema
123
+
124
+ Parse untrusted data into typed values. Accumulates all validation errors.
522
125
 
523
126
  ```typescript
524
127
  import {
@@ -528,197 +131,63 @@ import {
528
131
  optional,
529
132
  chain,
530
133
  string,
531
- minLength,
532
- maxLength,
533
- email,
534
134
  parseNumber,
535
- integer,
536
135
  min,
537
136
  max,
538
- array,
539
- minItems,
540
- maxItems,
541
- stringEnum,
542
- formatErrors,
543
137
  type InferSchemaType,
544
138
  } from '@railway-ts/pipelines/schema';
545
139
 
546
- const createUserSchema = object({
547
- username: required(chain(string(), minLength(3), maxLength(20))),
548
- email: required(chain(string(), email())),
549
- bio: optional(string()),
550
- age: required(chain(parseNumber(), integer(), min(13), max(120))),
551
- interests: required(chain(array(string()), minItems(1), maxItems(10))),
552
- role: required(stringEnum(['user', 'admin', 'moderator'] as const)),
140
+ const userSchema = object({
141
+ name: required(string()),
142
+ age: required(chain(parseNumber(), min(18), max(120))),
143
+ email: optional(string()),
553
144
  });
554
145
 
555
- type CreateUserInput = InferSchemaType<typeof createUserSchema>;
556
- // {
557
- // username: string;
558
- // email: string;
559
- // bio?: string;
560
- // age: number;
561
- // interests: string[];
562
- // role: 'user' | 'admin' | 'moderator';
563
- // }
564
-
565
- function createUser(input: unknown): Result<User, Record<string, string>> {
566
- const validated = validate(input, createUserSchema);
567
-
568
- return match(validated, {
569
- ok: (data) => saveToDatabase(data), // data is CreateUserInput
570
- err: (errors) => err(formatErrors(errors)),
571
- });
572
- }
573
- ```
574
-
575
- **See full validator catalog**: [`src/schema/`](src/schema/) (50+ validators)
576
- **See examples**: [`examples/schema/`](examples/schema/)
577
-
578
- ---
146
+ type User = InferSchemaType<typeof userSchema>;
147
+ // { name: string; age: number; email?: string }
579
148
 
580
- ### Composition: Build Complex Pipelines
149
+ const result = validate(input, userSchema);
150
+ // Result<User, ValidationError[]>
151
+ ```
581
152
 
582
- Functional composition utilities for building data pipelines.
153
+ **Primitives:** `string`, `number`, `boolean`, `date`, `bigint`
154
+ **Parsers:** `parseNumber`, `parseInt`, `parseFloat`, `parseJSON`, `parseString`, `parseBigInt`, `parseBool`, `parseDate`, `parseISODate`, `parseURL`, `parseEnum`
155
+ **Structures:** `object`, `array`, `tuple`, `tupleOf`
156
+ **Unions:** `union`, `discriminatedUnion`, `literal`
157
+ **Modifiers:** `required`, `optional`, `nullable`, `emptyAsOptional`
158
+ **String Constraints:** `minLength`, `maxLength`, `pattern`, `nonEmpty`, `email`, `phoneNumber`
159
+ **Number Constraints:** `min`, `max`, `integer`, `finite`, `between`
160
+ **Enums:** `stringEnum`, `numberEnum`
161
+ **Combinators:** `chain`, `transform`, `refine`, `matches`
162
+ **Utilities:** `validate`, `formatErrors`, `InferSchemaType`, `Validator`, `ValidationError`
583
163
 
584
- #### pipe() - Immediate Composition
164
+ ### Composition
585
165
 
586
- Left-to-right data flow with immediate execution:
166
+ Build pipelines. No nested function calls.
587
167
 
588
168
  ```typescript
589
- import { pipe } from '@railway-ts/pipelines/composition';
169
+ import { pipe, flow, curry } from '@railway-ts/pipelines/composition';
590
170
 
171
+ // Immediate execution
591
172
  const result = pipe(
592
173
  5,
593
- (x) => x * 2, // 10
594
- (x) => x + 1, // 11
595
- (x) => String(x), // "11"
596
- );
597
- // "11"
598
- ```
599
-
600
- Supports up to 10 steps with full type inference at each stage.
601
-
602
- #### flow() - Deferred Composition
603
-
604
- Build reusable function pipelines:
605
-
606
- ```typescript
607
- import { flow } from '@railway-ts/pipelines/composition';
608
-
609
- const processNumber = flow(
610
- (x: number) => x * 2,
611
- (x) => x + 1,
612
- (x) => String(x),
613
- );
614
-
615
- processNumber(5); // "11"
616
- processNumber(10); // "21"
617
- ```
618
-
619
- First function can accept multiple arguments:
620
-
621
- ```typescript
622
- const processSum = flow(
623
- (a: number, b: number) => a + b,
624
174
  (x) => x * 2,
625
- String,
626
- );
627
-
628
- processSum(3, 4); // "14"
629
- ```
630
-
631
- #### curry() / tupled()
632
-
633
- Convert between different function forms:
634
-
635
- ```typescript
636
- import { curry, tupled } from '@railway-ts/pipelines/composition';
637
-
638
- const add = (a: number, b: number) => a + b;
639
-
640
- // Currying for partial application
641
- const curriedAdd = curry(add);
642
- curriedAdd(5)(3); // 8
643
-
644
- pipe(5, curry(add)(10)); // 15
645
-
646
- // Tupled for array destructuring
647
- const tupledAdd = tupled(add);
648
- tupledAdd([5, 3]); // 8
649
- ```
650
-
651
- **See full API**: [`src/composition/`](src/composition/)
652
- **See examples**: [`examples/composition/`](examples/composition/)
653
-
654
- ---
655
-
656
- ## Quick Reference
657
-
658
- ### When to Use What
659
-
660
- | Scenario | Use | Why |
661
- | --------------------------- | ----------------- | ------------------------------------- |
662
- | Value might be absent | `Option<T>` | Absence is normal, not exceptional |
663
- | Operation can fail | `Result<T, E>` | Need to communicate why it failed |
664
- | Validating untrusted input | Schema validators | Parse into guaranteed-valid types |
665
- | Building data flows | `pipe`, `flow` | Compose transformations left-to-right |
666
- | Multi-step async operations | `andThen` | Chain async Results without nesting |
667
-
668
- ### Common Patterns
669
-
670
- **Boundary Validation**
671
-
672
- ```typescript
673
- // unknown -> Result<T, E>
674
- const result = validate(untrustedInput, schema);
675
- ```
676
-
677
- **Transform on Success Path**
678
-
679
- ```typescript
680
- // Stay on the rails
681
- const transformed = andThen(result, (data) => processData(data));
682
- ```
683
-
684
- **Branch at the End**
685
-
686
- ```typescript
687
- // Handle both paths once
688
- match(result, {
689
- ok: (data) => handleSuccess(data),
690
- err: (errors) => handleErrors(errors),
691
- });
692
- ```
693
-
694
- **Compose Reusable Pipelines**
175
+ (x) => x + 1,
176
+ ); // 11
695
177
 
696
- ```typescript
697
- // Build function pipelines
178
+ // Build reusable pipeline
698
179
  const process = flow(
699
- (input) => validate(input, schema),
700
- (r) => andThen(r, fetchData),
701
- (r) => andThen(r, transform),
180
+ (x: number) => x * 2,
181
+ (x) => x + 1,
702
182
  );
183
+ process(5); // 11
703
184
  ```
704
185
 
705
- **Combine Multiple Values**
186
+ **Functions:** `pipe`, `flow`, `curry`, `uncurry`, `tupled`, `untupled`
706
187
 
707
- ```typescript
708
- // All-or-nothing with Option
709
- const combined = combineOption([some(1), some(2), some(3)]); // Some([1,2,3])
710
-
711
- // Fail-fast with Result
712
- const results = combineResult([ok(1), ok(2), ok(3)]); // Ok([1,2,3])
713
- ```
714
-
715
- ---
188
+ ## Import Patterns
716
189
 
717
- ## Import Strategy
718
-
719
- The library provides **tree-shakable subpath imports**:
720
-
721
- ### Recommended: Subpath Imports
190
+ ### Subpath imports (recommended for tree-shaking)
722
191
 
723
192
  ```typescript
724
193
  import { some, none, map } from '@railway-ts/pipelines/option';
@@ -727,135 +196,39 @@ import { pipe, flow } from '@railway-ts/pipelines/composition';
727
196
  import { string, number, validate } from '@railway-ts/pipelines/schema';
728
197
  ```
729
198
 
730
- **Benefits:**
731
-
732
- - Optimal tree-shaking
733
- - Natural function names (both Option and Result have `map`, no conflicts)
734
- - Import only what you need
735
-
736
- ### Alternative: Root Import
199
+ ### Root imports (adds type suffixes)
737
200
 
738
201
  ```typescript
739
- import {
740
- mapOption, // Option's map
741
- mapResult, // Result's map
742
- pipe,
743
- ok,
744
- validate,
745
- } from '@railway-ts/pipelines';
202
+ import { mapOption, mapResult, pipe, ok, validate } from '@railway-ts/pipelines';
746
203
  ```
747
204
 
748
- **When to use:** Convenience when you need functions from multiple modules and don't mind renamed functions.
749
-
750
- **Module structure:**
751
-
752
- | Import Path | Module | Key Types |
753
- | ----------------------------------- | -------------------- | ----------------- |
754
- | `@railway-ts/pipelines/option` | Optional values | `Option<T>` |
755
- | `@railway-ts/pipelines/result` | Fallible operations | `Result<T, E>` |
756
- | `@railway-ts/pipelines/schema` | Validation | `Validator<I, O>` |
757
- | `@railway-ts/pipelines/composition` | Function composition | `pipe`, `flow` |
758
-
759
- ---
760
-
761
- ## Is This Right For You?
762
-
763
- ### When to Use railway-ts/pipelines
764
-
765
- **Choose railway-ts/pipelines when:**
766
-
767
- - Building data transformation pipelines
768
- - You want validation + error handling + composition in one integrated system
769
- - You prefer functional composition over method chaining
770
- - You need explicit error propagation through multi-step workflows
771
- - You want a gentler FP learning curve than fp-ts or Effect-TS
772
-
773
- ### When to Choose Something Else
774
-
775
- **Choose an alternative when:**
776
-
777
- - You only need validation (Zod is simpler for just validation)
778
- - You need a full effect system with DI and resource management (Effect-TS)
779
- - You want comprehensive category theory abstractions (fp-ts)
780
- - You prefer OOP or method chaining style
781
-
782
- ### Quick Comparison
783
-
784
- | Library | Scope | Philosophy | Best For |
785
- | -------------- | ---------------------------- | ------------------------------- | ---------------------------- |
786
- | **railway-ts** | Railway pattern + validation | Railway-oriented programming | Data pipelines, pragmatic FP |
787
- | **Zod** | Validation only | Declarative schemas | Drop-in validation |
788
- | **fp-ts** | Complete FP toolkit | Category theory | FP purists, complex apps |
789
- | **Effect-TS** | Full effect system | Effect/Fiber/Layer architecture | Large apps, microservices |
790
-
791
- ---
792
-
793
- ## Next Steps
794
-
795
- ### API Reference
205
+ Functions that exist in both Result and Option get suffixes when imported from root: `mapResult`, `mapOption`, etc. Result-only functions stay unsuffixed: `mapErr`, `andThen`.
796
206
 
797
- Full API documentation with types and examples:
207
+ ## Examples
798
208
 
799
- - **Option**: [`src/option/option.ts`](src/option/option.ts) - 21 functions
800
- - **Result**: [`src/result/result.ts`](src/result/result.ts) - 27 functions
801
- - **Composition**: [`src/composition/`](src/composition/) - 6 utilities
802
- - **Schema**: [`src/schema/`](src/schema/) - 50+ validators
209
+ Clone and run:
803
210
 
804
- ### Examples
805
-
806
- Working examples organized by category:
807
-
808
- - **Option**: [`examples/option/option-examples.ts`](examples/option/option-examples.ts)
809
- - **Result**: [`examples/result/result-examples.ts`](examples/result/result-examples.ts)
810
- - **Schema**: [`examples/schema/`](examples/schema/)
811
- - [`basic.ts`](examples/schema/basic.ts) - Basic validators
812
- - [`union.ts`](examples/schema/union.ts) - Union types
813
- - [`tuple.ts`](examples/schema/tuple.ts) - Tuple validation
814
- - **Composition**: [`examples/composition/`](examples/composition/)
815
- - [`advanced-composition.ts`](examples/composition/advanced-composition.ts)
816
- - [`curry-basics.ts`](examples/composition/curry-basics.ts)
817
- - [`tupled-basics.ts`](examples/composition/tupled-basics.ts)
818
- - **Complete Pipelines**: [`examples/complete-pipelines/`](examples/complete-pipelines/)
819
- - [`async.ts`](examples/complete-pipelines/async.ts) - Basic async pipeline
820
- - [`async-launch.ts`](examples/complete-pipelines/async-launch.ts) - Rocket launch decision
821
- - [`hohmann-transfer.ts`](examples/complete-pipelines/hohmann-transfer.ts) - Orbital mechanics
822
- - [`hill-clohessy-wiltshire.ts`](examples/complete-pipelines/hill-clohessy-wiltshire.ts) - Spacecraft rendezvous
823
- - **Interop**: [`examples/interop/interop-examples.ts`](examples/interop/interop-examples.ts)
824
-
825
- ### Advanced Topics
826
-
827
- For advanced implementation details:
828
-
829
- - **Symbol Branding**: How types are protected from structural typing
830
- - **Tuple-Preserving Combinators**: Type-level magic for `combine()`
831
- - **Type Inference**: How schema types are extracted
832
-
833
- See [`docs/ADVANCED.md`](docs/ADVANCED.md)
834
-
835
- ### Contributing
836
-
837
- Interested in contributing? See [`CONTRIBUTING.md`](CONTRIBUTING.md) for:
211
+ ```bash
212
+ git clone https://github.com/sakobu/railway-ts-pipelines.git
213
+ cd railway-ts-pipelines
214
+ bun install
215
+ bun run examples/index.ts
216
+ ```
838
217
 
839
- - Development setup and commands
840
- - Project structure
841
- - Code style guidelines
842
- - Testing patterns
843
- - Pull request process
218
+ **What's in there:**
844
219
 
845
- ---
220
+ - `option/` - Nullable handling patterns
221
+ - `result/` - Error handling patterns
222
+ - `schema/` - Validation (basic, unions, tuples)
223
+ - `composition/` - Function composition techniques
224
+ - `complete-pipelines/` - Full examples with validation + async + logic
846
225
 
847
- ## Further Reading
226
+ Start with `examples/complete-pipelines/async-launch.ts` for a real-world pattern.
848
227
 
849
- - [Railway-Oriented Programming](https://fsharpforfunandprofit.com/rop/) - Original concept by Scott Wlaschin
850
- - [Parse, Don't Validate](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/) - Type-driven validation
851
- - [Making Illegal States Unrepresentable](https://ybogomolov.me/making-illegal-states-unrepresentable) - Type-level constraints
228
+ ## Contributing
852
229
 
853
- ---
230
+ [CONTRIBUTING.md](CONTRIBUTING.md)
854
231
 
855
232
  ## License
856
233
 
857
234
  MIT © Sarkis Melkonian
858
-
859
- ---
860
-
861
- **Build robust pipelines. Make errors boring. Let data flow.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@railway-ts/pipelines",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Functional programming abstractions for TypeScript: Build robust data pipelines with schema validation, Option, Result, and railway-oriented programming.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -34,8 +34,13 @@
34
34
  }
35
35
  },
36
36
  "files": [
37
- "dist"
37
+ "dist",
38
+ "README.md"
38
39
  ],
40
+ "sideEffects": false,
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
39
44
  "scripts": {
40
45
  "build": "bunx tsup",
41
46
  "build:watch": "bunx tsup --watch",
@@ -67,30 +72,29 @@
67
72
  "schema",
68
73
  "pipeline"
69
74
  ],
70
- "repository": {
71
- "type": "git",
72
- "url": "git+https://github.com/sakobu/railway-ts-pipelines.git"
73
- },
75
+ "repository": "github:sakobu/railway-ts-pipelines",
74
76
  "homepage": "https://github.com/sakobu/railway-ts-pipelines#readme",
75
77
  "bugs": {
76
78
  "url": "https://github.com/sakobu/railway-ts-pipelines/issues"
77
79
  },
78
- "sideEffects": false,
79
80
  "author": "Sarkis Melkonian",
80
81
  "license": "MIT",
82
+ "publishConfig": {
83
+ "access": "public"
84
+ },
81
85
  "devDependencies": {
82
- "@eslint/js": "^9.36.0",
83
- "@types/bun": "latest",
84
- "eslint": "^9.36.0",
86
+ "@eslint/js": "^9.39.1",
87
+ "@types/bun": "1.3.2",
88
+ "eslint": "^9.39.1",
85
89
  "eslint-import-resolver-typescript": "^4.4.4",
86
90
  "eslint-plugin-import": "^2.32.0",
87
91
  "eslint-plugin-prettier": "^5.5.4",
88
92
  "eslint-plugin-security": "^3.0.1",
89
- "eslint-plugin-unicorn": "^61.0.2",
90
- "globals": "^16.4.0",
93
+ "eslint-plugin-unicorn": "^62.0.0",
94
+ "globals": "^16.5.0",
91
95
  "prettier": "^3.6.2",
92
96
  "tsup": "^8.5.0",
93
- "typescript-eslint": "^8.44.1"
97
+ "typescript-eslint": "^8.46.4"
94
98
  },
95
99
  "peerDependencies": {
96
100
  "typescript": "^5"