0.1.1 • Published 4 months ago

railway-ts v0.1.1

Weekly downloads
-
License
MIT
Repository
-
Last release
4 months ago

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 value
  • none<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 value
  • isNone<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 none
  • unwrapOr<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 present
  • flatMap<T, U>(option: Option<T>, f: (value: T) => Option<U>): Option<U> - Maps and flattens
  • filter<T>(option: Option<T>, predicate: (value: T) => boolean): Option<T> - Filters value
  • combine<T>(options: Option<T>[]): Option<T[]> - Combines array of Options
  • sequence<T>(options: Option<T>[]): Option<T[]> - Alias for combine
  • traverse<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 matching
  • tap<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 Result
  • err<E>(error: E): Result<never, E> - Creates a failure Result
  • fromTry<T>(f: () => T): Result<T, Error> - Converts a function that might throw
  • fromPromise<T>(promise: Promise<T>): Promise<Result<T, Error>> - Converts a Promise
  • fromAsyncTry<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 successful
  • isErr<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 error
  • unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T - Extracts or returns default
  • unwrapOrElse<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 value
  • mapErr<T, E, F>(result: Result<T, E>, f: (error: E) => F): Result<T, F> - Maps the error
  • flatMap<T, E, U>(result: Result<T, E>, f: (value: T) => Result<U, E>): Result<U, E> - Chains Results
  • combine<T, E>(results: Result<T, E>[]): Result<T[], E> - Combines array of Results
  • combineAll<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 right
  • flow<A, B, C, ...>(ab: (...a: A) => B, bc: (b: B) => C, ...): (...a: A) => ... - Creates a composed function
  • pipeAsync<A, B, C, ...>(a: A, ab: (a: A) => Promise<B> | B, ...): Promise<...> - Async version of pipe
  • flowAsync<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

0.1.1

4 months ago

0.1.0

4 months ago