railway-ts v0.1.1
Railway-ts
A lightweight, functional programming library for TypeScript that implements Railway Oriented Programming with Option and Result types.
Overview
Railway-ts provides a practical approach to functional programming in TypeScript by treating errors as values rather than exceptions. It offers:
- Option type: Handle nullable values in a functional way
- Result type: Express success or failure without exceptions
- Utility functions: Compose operations with pipe, flow, and more
- Tree-shakable: Import only what you need
- Zero dependencies: Lightweight and fast
Installation
# Using npm
npm install railway-ts
# Using yarn
yarn add railway-ts
# Using pnpm
pnpm add railway-ts
# Using bun
bun add railway-ts
Basic Usage
Working with Option
Using direct imports from the option module:
import { some, none, isSome, isNone, unwrap, unwrapOr } from "railway-ts/option";
// Creating Options
const validValue = some(42);
const noValue = none<number>();
// Type guards
if (isSome(validValue)) {
console.log(validValue.value); // 42
}
if (isNone(noValue)) {
console.log("No value present");
}
// Unwrapping with fallback
const result = unwrapOr(validValue, 0); // 42
const fallback = unwrapOr(noValue, 0); // 0
Alternatively, using root imports with suffixed function names:
import { some, none, isSome, isNone, unwrapOptionOr } from "railway-ts";
// Same usage but with renamed functions
const result = unwrapOptionOr(validValue, 0); // 42
Working with Result
Using direct imports from the result module:
import { ok, err, isOk, isErr, unwrapOr, fromTry } from "railway-ts/result";
// Creating Results
const success = ok(42);
const failure = err("Something went wrong");
// Type guards
if (isOk(success)) {
console.log(success.value); // 42
}
if (isErr(failure)) {
console.log(failure.error); // 'Something went wrong'
}
// Converting exceptions to Results
const parseJson = (input: string) => fromTry(() => JSON.parse(input));
const validJson = parseJson('{"key": "value"}');
const invalidJson = parseJson('{"key: value}');
// Safe unwrapping
console.log(unwrapOr(validJson, {})); // {key: 'value'}
console.log(unwrapOr(invalidJson, {})); // {}
Alternatively, using root imports with suffixed function names:
import { ok, err, isOk, isErr, unwrapResultOr, fromTryResult } from "railway-ts";
// Same usage but with renamed functions
const parseJson = (input: string) => fromTryResult(() => JSON.parse(input));
console.log(unwrapResultOr(validJson, {})); // {key: 'value'}
Function Composition
Using direct imports from specific modules:
import { pipe, flow } from "railway-ts/utils";
import { ok, map, flatMap } from "railway-ts/result";
// Using pipe for immediate execution
const result = pipe(
5,
(n) => n * 2,
(n) => n + 1,
); // 11
// Using flow to create reusable transformations
const processNumber = flow(
(n: number) => n * 2,
(n) => n + 1,
(n) => n.toString(),
);
console.log(processNumber(5)); // "11"
// With Result type for error handling
const validatePositive = (n: number) => (n > 0 ? ok(n) : err("Number must be positive"));
const computeWithValidation = flow(
(n: number) => ok(n),
(r) => flatMap(r, validatePositive),
(r) => map(r, (n) => n * 2),
);
console.log(computeWithValidation(5)); // ok(10)
console.log(computeWithValidation(-5)); // err('Number must be positive')
Alternatively, using root imports with suffixed function names:
import { pipe, flow, ok, err, mapResult, flatMapResult } from "railway-ts";
const computeWithValidation = flow(
(n: number) => ok(n),
(r) => flatMapResult(r, validatePositive),
(r) => mapResult(r, (n) => n * 2),
);
Import Structure
Railway-ts provides two ways to import its functionality:
Root Imports (with Namespacing)
When importing from the root module, functions are renamed with suffixes to avoid naming conflicts between Option and Result types:
import {
// Option functions with suffixes
some,
none,
unwrapOption,
mapOption,
flatMapOption,
// Result functions with suffixes
ok,
err,
unwrapResult,
mapResult,
flatMapResult,
// Utility functions without suffixes (no conflicts)
pipe,
flow,
memoize,
} from "railway-ts";
const opt = mapOption(some(5), (n) => n * 2);
const res = mapResult(ok(5), (n) => n * 2);
Direct Module Imports
For cleaner code, import directly from specific modules to avoid the renamed functions:
// Option module with original function names
import { some, none, unwrap, map, flatMap } from "railway-ts/option";
// Result module with original function names
import { ok, err, unwrap, map, flatMap } from "railway-ts/result";
// Utility functions
import { pipe, flow } from "railway-ts/utils";
const opt = map(some(5), (n) => n * 2);
const res = map(ok(5), (n) => n * 2);
Which Import Style to Use?
- Root imports: When working with both Option and Result in the same file
- Direct imports: When primarily working with just one type (Option or Result)
- Mixed approach: Import specific items from modules and others from root as needed
API Reference
Option Type
An Option represents a value that may or may not be present.
Constructors
some<T>(value: T): Option<T>
- Creates an Option containing a valuenone<T>(): Option<T>
- Creates an Option representing the absence of a value
Type Guards
isSome<T>(option: Option<T>): boolean
- Checks if an Option contains a valueisNone<T>(option: Option<T>): boolean
- Checks if an Option represents no value
Unwrapping
unwrap<T>(option: Option<T>, errorMessage?: string): T
- Extracts the value or throws if noneunwrapOr<T>(option: Option<T>, defaultValue: T): T
- Extracts the value or returns a default
Transformations
map<T, U>(option: Option<T>, f: (value: T) => U): Option<U>
- Maps the value if presentflatMap<T, U>(option: Option<T>, f: (value: T) => Option<U>): Option<U>
- Maps and flattensfilter<T>(option: Option<T>, predicate: (value: T) => boolean): Option<T>
- Filters valuecombine<T>(options: Option<T>[]): Option<T[]>
- Combines array of Optionssequence<T>(options: Option<T>[]): Option<T[]>
- Alias for combinetraverse<T, U>(array: T[], f: (value: T) => Option<U>): Option<U[]>
- Maps and combines
Utilities
match<T, U>(option: Option<T>, patterns: { some: (value: T) => U, none: () => U }): U
- Pattern matchingtap<T>(option: Option<T>, f: (value: T) => void): Option<T>
- Side effects without changing value
Result Type
A Result represents either success (Ok) or failure (Err).
Constructors
ok<T>(value: T): Result<T, never>
- Creates a successful Resulterr<E>(error: E): Result<never, E>
- Creates a failure ResultfromTry<T>(f: () => T): Result<T, Error>
- Converts a function that might throwfromPromise<T>(promise: Promise<T>): Promise<Result<T, Error>>
- Converts a PromisefromAsyncTry<T>(f: () => Promise<T>): Promise<Result<T, Error>>
- Converts async function
Type Guards
isOk<T, E>(result: Result<T, E>): boolean
- Checks if a Result is successfulisErr<T, E>(result: Result<T, E>): boolean
- Checks if a Result is a failure
Unwrapping
unwrap<T, E>(result: Result<T, E>): T
- Extracts the value or throws if errorunwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T
- Extracts or returns defaultunwrapOrElse<T, E>(result: Result<T, E>, defaultValue: () => T): T
- With lazy default
Transformations
map<T, E, U>(result: Result<T, E>, f: (value: T) => U): Result<U, E>
- Maps the valuemapErr<T, E, F>(result: Result<T, E>, f: (error: E) => F): Result<T, F>
- Maps the errorflatMap<T, E, U>(result: Result<T, E>, f: (value: T) => Result<U, E>): Result<U, E>
- Chains Resultscombine<T, E>(results: Result<T, E>[]): Result<T[], E>
- Combines array of ResultscombineAll<T, E>(results: Result<T, E>[]): Result<T[], E[]>
- Combines with all errors
Asynchronous Operations
mapAsync<T, E, U>(result: Result<T, E>, f: (value: T) => Promise<U>): Promise<Result<U, E>>
flatMapAsync<T, E, U>(result: Result<T, E>, f: (value: T) => Promise<Result<U, E>>): Promise<Result<U, E>>
retryAsync<T, E>(fn: () => Promise<Result<T, E>>, maxRetries: number, delayMs: number): Promise<Result<T, E>>
withTimeout<T, E>(fn: () => Promise<Result<T, E>>, timeoutMs: number): Promise<Result<T, E | Error>>
Utility Functions
Composition
pipe<A, B, C, ...>(a: A, ab: (a: A) => B, bc: (b: B) => C, ...): ...
- Executes functions left to rightflow<A, B, C, ...>(ab: (...a: A) => B, bc: (b: B) => C, ...): (...a: A) => ...
- Creates a composed functionpipeAsync<A, B, C, ...>(a: A, ab: (a: A) => Promise<B> | B, ...): Promise<...>
- Async version of pipeflowAsync<A, B, C, ...>(ab: (...a: A) => Promise<B> | B, ...): (...a: A) => Promise<...>
- Async flow
Memoization
memoize<R, Args extends unknown[]>(fn: (...args: Args) => R, keyFn?: (...args: Args) => string): (...args: Args) => R
memoizeWithLimit<R, Args extends unknown[]>(fn: (...args: Args) => R, maxSize?: number, keyFn?: (...args: Args) => string): (...args: Args) => R
Design Principles
Railway Oriented Programming
Railway-ts follows the "Railway Oriented Programming" pattern where functions can have two tracks: success and failure. This helps avoid deeply nested error handling and create more maintainable code.
Functional Over Object-Oriented
The library favors a functional approach using pure functions rather than classes or methods. This makes code more composable and easier to reason about.
Tree-Shakable Modules
Each function is separately importable to keep bundle sizes small:
// Import specific functions from submodules
import { ok, err, map } from "railway-ts/result";
import { some, none } from "railway-ts/option";
import { pipe, flow } from "railway-ts/utils";
// Or import from root (with renamed functions to avoid conflicts)
import { ok, err, mapResult, some, none, mapOption, pipe, flow } from "railway-ts";
// Or import everything
import * as R from "railway-ts";
Export Structure
The library is organized into several modules:
railway-ts/
├── index.ts # Re-exports with namespaced functions
├── option/ # Option type and related functions
├── result/ # Result type and related functions
└── utils/ # Utility functions (pipe, flow, memoize)
When using functions from both Option and Result modules in the same file, use the root import to avoid naming conflicts:
import { mapOption, mapResult } from "railway-ts";
// Instead of:
// import { map } from 'railway-ts/option';
// import { map } from 'railway-ts/result'; // Error: Duplicate identifier 'map'
Error Handling Philosophy
Railway-ts focuses on handling errors as values rather than exceptions:
// Without Railway-ts
try {
const data = JSON.parse(input);
// Process data...
} catch (error) {
// Handle error...
}
// With Railway-ts
const result = fromTry(() => JSON.parse(input));
if (isOk(result)) {
// Process data...
} else {
// Handle error...
}
// Or with pattern matching
match(result, {
ok: data => /* process data */,
err: error => /* handle error */
});
Error Handling in pipe/flow
The pipe
and flow
functions don't automatically handle exceptions. To incorporate error handling:
// Convert exceptions to Results within the pipe
pipe(
getUserInput(),
(input) => fromTry(() => parseData(input)),
(result) => map(result, processData),
);
Performance Considerations
Memoization
The memoize
and memoizeWithLimit
functions use JSON.stringify
by default for generating cache keys, which has some limitations:
- Does not handle circular references
- Functions are omitted from keys
- Symbols are omitted from keys
For complex arguments, provide a custom key function:
const memoizedFn = memoize(
expensiveOperation,
(obj, id) => `${id}-${obj.name}`, // Custom key generation
);
// With size limit
const limitedCache = memoizeWithLimit(
fetchData,
100, // Max cache size
(userId) => userId.toString(),
);
License
MIT © Sarkis Melkonian