Skip to content

A comprehensive TypeScript library bringing Scala's powerful functional programming paradigms to JavaScript/TypeScript, featuring immutable collections, monads, pattern matching, and more

License

Notifications You must be signed in to change notification settings

chrismichaelps/scats

Repository files navigation

Scats Logo
npm version npm downloads license stars

A comprehensive TypeScript library bringing Scala's powerful functional programming paradigms to JavaScript/TypeScript, featuring immutable collections, monads, pattern matching, and more

Table of Contents

Features

  • Algebraic Data Types (ADTs) via tagged unions
  • Pattern matching inspired by Scala 3
  • Immutable collections (List, Map, Set)
  • Lazy evaluation with LazyList
  • Efficient indexed sequences with Vector
  • Option, Either, Try containers
  • For-comprehensions for monadic composition
  • Typeclasses with extension methods
  • Tuples with Tuple2 and Tuple3 implementations
  • Ordering for comparison operations
  • Resource management with Using pattern
  • Monads including State and Writer

Installation

npm

npm install @chris5855/scats

yarn

yarn add @chris5855/scats

pnpm

pnpm add @chris5855/scats

Development

Setup

# Install dependencies
npm install

# Build the project
npm run build

# Run examples
npm run example

# Run tests
npm run test

Getting Started

Option: Handling nullable values

import { Option, Some, None } from "@chris5855/scats";

// Creating options
const a = Some(42);
const b = None;
const c = Option.fromNullable(maybeNull);

// Using options
const result = a
  .map((n) => n * 2)
  .flatMap((n) => (n > 50 ? Some(n) : None))
  .getOrElse(0);

Either: Handling success/failure

import { Either, Left, Right } from "@chris5855/scats";

// Creating eithers
const success = Right(42);
const failure = Left(new Error("Something went wrong"));

// Using eithers
const result = success
  .map((n) => n * 2)
  .fold(
    (err) => `Error: ${err.message}`,
    (value) => `Success: ${value}`
  );

Try: Handling exceptions

import { Try, Success, Failure, TryAsync } from "@chris5855/scats";

// Synchronous Try
const jsonResult = Try.of(() => JSON.parse(jsonString))
  .map((data) => data.value)
  .recover((err) => "default value")
  .get();

// Asynchronous Try
const asyncResult = await TryAsync.of(async () => {
  const response = await fetch("https://api.example.com");
  return response.json();
})
  .map((data) => data.value)
  .recover((err) => "error occurred")
  .toPromise();

Pattern Matching

import {
  match,
  when,
  otherwise,
  extract,
  value,
  array,
  or,
  and,
  not,
  type as matchType,
  object,
} from "@chris5855/scats";

// Simple value matching
const result = match(42)
  .with(1, () => "one")
  .with(2, () => "two")
  .with(
    when((n) => n > 10),
    (n) => `greater than 10: ${n}`
  )
  .otherwise(() => "default case")
  .run();

// Object pattern matching
const person = { name: "John", age: 30 };
const greeting = match(person)
  .with(
    object({ name: "John", age: when<number>((a) => a > 18) }),
    () => "Hello Mr. John"
  )
  .with(object({ name: "John" }), () => "Hello John")
  .otherwise(() => "Hello stranger")
  .run();

// Advanced pattern matching
// Extract pattern
match(person)
  .with(
    extract((p) => p.name),
    (name) => `Name is: ${name}`
  )
  .otherwise(() => "No match")
  .run();

// Array pattern
match([1, 2, 3])
  .with(array([1, 2, 3]), () => "exact match")
  .with(array([1, when((n) => n > 1), 3]), () => "pattern match")
  .otherwise(() => "no match")
  .run();

// Combining patterns with or, and, not
match(42)
  .with(or(value(41), value(42), value(43)), () => "one of 41, 42, 43")
  .with(
    and(
      when((n) => n > 40),
      when((n) => n < 50)
    ),
    () => "between 40 and 50"
  )
  .with(not(value(100)), () => "anything but 100")
  .otherwise(() => "no match")
  .run();

// Type matching
class Cat {}
class Dog {}
match(new Cat())
  .with(matchType(Cat), () => "It's a cat")
  .with(matchType(Dog), () => "It's a dog")
  .otherwise(() => "unknown animal")
  .run();

Immutable Collections

import { List, Map, Set, ArraySeq, ArrayBuffer } from "@chris5855/scats";

// List
const numbers = List.of(1, 2, 3, 4, 5);
const doubled = numbers.map((n) => n * 2);
const sum = numbers.foldLeft(0, (acc, n) => acc + n);
const evens = numbers.filter((n) => n % 2 === 0);

// Map
const userMap = Map.of([
  ["user1", { name: "Alice", age: 25 }],
  ["user2", { name: "Bob", age: 30 }],
]);
const hasUser = userMap.has("user1");
const olderUsers = userMap.filter((user) => user.age > 25);

// Set
const uniqueNumbers = Set.of(1, 2, 3, 2, 1);
const union = uniqueNumbers.union(Set.of(3, 4, 5));
const intersection = uniqueNumbers.intersection(Set.of(2, 3, 4));
const difference = uniqueNumbers.difference(Set.of(2, 3));

// ArraySeq (IndexedSeq)
const seq = new ArraySeq([1, 2, 3, 4, 5]);
const mappedSeq = seq.map((x) => x * 2);
console.log(Array.from(mappedSeq).join(", ")); // "2, 4, 6, 8, 10"

// ArrayBuffer (mutable Buffer)
const buffer = new ArrayBuffer<number>();
buffer.append(1).append(2).append(3);
console.log(Array.from(buffer).join(", ")); // "1, 2, 3"
buffer.prepend(0);
console.log(Array.from(buffer).join(", ")); // "0, 1, 2, 3"

LazyList

import { LazyList } from "@chris5855/scats";

// Creating a LazyList
const numbers = LazyList.of(1, 2, 3, 4, 5);

// Creating an infinite sequence of numbers
const naturals = LazyList.from(1).iterate((n) => n + 1);

// Taking only what you need
const first10 = naturals.take(10).toArray(); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

// Lazy transformation
const evenSquares = naturals
  .filter((n) => n % 2 === 0) // Only even numbers
  .map((n) => n * n) // Square them
  .take(5) // Take first 5
  .toArray(); // [4, 16, 36, 64, 100]

// Generate a range of numbers
const range = LazyList.range(1, 10); // 1 to 9

// Create from iterable
const fromArray = LazyList.from([1, 2, 3]);

// Creating an infinite stream with a generator function
const randomNumbers = LazyList.continually(() => Math.random())
  .take(3)
  .toArray(); // Three random numbers

// Iterate from a seed value
const powers = LazyList.iterate(1, (n) => n * 2)
  .take(5)
  .toArray(); // [1, 2, 4, 8, 16]

Vector

import { Vector } from "@chris5855/scats";

// Creating a Vector
const vec = Vector.of(1, 2, 3, 4, 5);

// Accessing elements (constant time)
const third = vec.apply(2); // 3
const maybeValue = vec.get(10); // None (out of bounds)

// Modifying elements
const updated = vec.updated(2, 10); // Vector(1, 2, 10, 4, 5)

// Adding elements
const appended = vec.appended(6); // Vector(1, 2, 3, 4, 5, 6)
const prepended = vec.prepended(0); // Vector(0, 1, 2, 3, 4, 5)

// Combining vectors
const combined = vec.appendAll(Vector.of(6, 7, 8)); // Vector(1, 2, 3, 4, 5, 6, 7, 8)

// Transforming vectors
const doubled = vec.map((n) => n * 2); // Vector(2, 4, 6, 8, 10)
const even = vec.filter((n) => n % 2 === 0); // Vector(2, 4)

// Flattening
const vectors = Vector.of(Vector.of(1, 2), Vector.of(3, 4));
const flattened = vectors.flatMap((v) => v); // Vector(1, 2, 3, 4)

// Static constructors
const empty = Vector.empty<number>(); // Empty vector
const fromArray = Vector.from([1, 2, 3]); // Vector from array

For-Comprehensions

import {
  For,
  Some,
  None,
  List,
  ForComprehensionBuilder,
  Monad,
} from "@chris5855/scats";

// Option comprehension
const optionResult = For.option<{ a: number; b: number; c: number }>()
  .bind("a", () => Some(1))
  .bind("b", ({ a }) => Some(a + 1))
  .bind("c", ({ a, b }) => Some(a + b))
  .yield(({ a, b, c }) => a + b + c); // Some(6)

// List comprehension
const matrix = For.list<{ row: number; col: string }>()
  .bind("row", () => List.of(1, 2, 3))
  .bind("col", () => List.of("A", "B"))
  .yield(({ row, col }) => `${row}${col}`); // List(1A, 1B, 2A, 2B, 3A, 3B)

// Custom monad example
class Identity<A> implements Monad<A> {
  constructor(private readonly value: A) {}

  map<B>(f: (a: A) => B): Identity<B> {
    return new Identity(f(this.value));
  }

  flatMap<B>(f: (a: A) => Identity<B>): Identity<B> {
    return f(this.value);
  }

  get(): A {
    return this.value;
  }

  static of<A>(a: A): Identity<A> {
    return new Identity(a);
  }
}

// Creating a custom comprehension
const builder = new ForComprehensionBuilder<Identity<any>, {}>();
const idComp = builder.custom(
  <A>(a: A) => Identity.of(a),
  <A>(ma: Identity<A>, f: (a: A) => Identity<any>) => ma.flatMap(f)
);

const result = idComp
  .bind("x", () => Identity.of(10))
  .bind("y", (env) => Identity.of((env as any).x * 2))
  .yield((env) => (env as any).x + (env as any).y); // Identity(30)

Type Classes

import {
  TypeClass,
  TypeClassRegistry,
  register,
  extension,
  withContext,
} from "@chris5855/scats";

// Define a type class
interface Numeric<T> extends TypeClass<T> {
  add(a: T, b: T): T;
  zero(): T;
}

// Create instances for different types
const numberNumeric: Numeric<number> = {
  __type: undefined as any as number,
  add: (a, b) => a + b,
  zero: () => 0,
};

const stringNumeric: Numeric<string> = {
  __type: undefined as any as string,
  add: (a, b) => a + b,
  zero: () => "",
};

// Register instances in a registry
const registry = new TypeClassRegistry<Numeric<any>>();
registry.register(numberNumeric, Number);
registry.register(stringNumeric, String);

// Using the registry
function sum<T>(values: T[], registry: TypeClassRegistry<Numeric<any>>): T {
  if (values.length === 0) throw new Error("Cannot sum empty array");
  const numeric = registry.getFor(values[0]);
  return values.reduce((acc, val) => numeric.add(acc, val), numeric.zero());
}

// Example usage
console.log(sum([1, 2, 3, 4], registry)); // 10
console.log(sum(["a", "b", "c"], registry)); // "abc"

// Using extension methods
const getNumberValue = (value: any) => value as number;
const addMethod = extension<number, Numeric<number>>(
  registry,
  getNumberValue
)("add");

// Using context bounds
withContext<number, Numeric<number>>(registry, (numeric) => {
  const result = numeric.add(5, 10);
  console.log(result); // 15
});

// Using the registry directly
const numeric = registry.getFor(5);
console.log(`Add with type class: ${numeric.add(5, 10)}`); // Add with type class: 15

Tuples

import { Tuple, Tuple2, Tuple3 } from "@chris5855/scats";

// Creating tuples
const pair = Tuple.of(1, "hello");
const triple = Tuple.of(1, "hello", true);

// Accessing elements
const first = pair._1; // 1
const second = pair._2; // "hello"
const third = triple._3; // true

// Destructuring
const [num, str] = pair;
const [x, y, z] = triple;

// Tuple operations
const swapped = pair.swap(); // Tuple2("hello", 1)
const mappedFirst = pair.map1((n) => n * 2); // Tuple2(2, "hello")
const mappedSecond = pair.map2((s) => s.toUpperCase()); // Tuple2(1, "HELLO")
const mapped = triple.map(
  (n) => n * 2,
  (s) => s.toUpperCase(),
  (b) => !b
); // Tuple3(2, "HELLO", false)

// Creating tuples from arrays
const pairFromArray = Tuple.fromArray2([1, "hello"]);
const tripleFromArray = Tuple.fromArray3([1, "hello", true]);

// Creating a tuple from a Map entry
const entry: [string, number] = ["key", 123];
const entryTuple = Tuple.fromEntry(entry); // Tuple2("key", 123)

Ordering

import { Ordering } from "@chris5855/scats";

// Using built-in orderings
const numbers = [3, 1, 4, 1, 5, 9];
const sortedNumbers = [...numbers].sort((a, b) =>
  Ordering.number.compare(a, b)
);
// sortedNumbers is [1, 1, 3, 4, 5, 9]

const strings = ["banana", "apple", "cherry"];
const sortedStrings = [...strings].sort((a, b) =>
  Ordering.string.compare(a, b)
);
// sortedStrings is ["apple", "banana", "cherry"]

// Finding min/max values
const min = Ordering.number.min(10, 5); // 5
const max = Ordering.number.max(10, 5); // 10

// Creating a custom ordering
type Person = { name: string; age: number };
const people = [
  { name: "Alice", age: 30 },
  { name: "Bob", age: 25 },
  { name: "Charlie", age: 35 },
];

// Order by age
const byAge = Ordering.by<Person, number>((p) => p.age);
const sortedByAge = [...people].sort((a, b) => byAge.compare(a, b));
// First person is Bob (age 25)

// Order by name
const byName = Ordering.by<Person, string>((p) => p.name);
const sortedByName = [...people].sort((a, b) => byName.compare(a, b));
// First person is Alice

// Reverse ordering
const descendingOrder = Ordering.number.reverse();
const sortedDesc = [...numbers].sort((a, b) => descendingOrder.compare(a, b));
// sortedDesc is [9, 5, 4, 3, 1, 1]

// Chaining orderings (e.g., sort by lastname, then by firstname)
const byLastname = Ordering.by<Person, string>((p) => p.lastname);
const byFirstname = Ordering.by<Person, string>((p) => p.firstname);
const byLastThenFirst = byLastname.andThen(byFirstname);

Resource Management

import { Using, Closeable, Success } from "@chris5855/scats";

// Create a resource that needs to be closed
class Resource implements Closeable {
  constructor(readonly id: string) {
    console.log(`Resource ${id} created`);
  }

  getData(): string {
    return `Data from resource ${this.id}`;
  }

  close(): void {
    console.log(`Resource ${id} closed`);
  }
}

// Use a single resource and ensure it gets closed
const result = Using.resource(new Resource("123"), (resource) => {
  return resource.getData().toUpperCase();
});
// Result is: Success(DATA FROM RESOURCE 123)
// resource.close() is guaranteed to be called, even if an exception is thrown

// Using multiple resources
const multiResult = Using.resources(
  [new Resource("A"), new Resource("B")],
  ([resourceA, resourceB]) => {
    return resourceA.getData() + " + " + resourceB.getData();
  }
);
// Result is: Success(Data from resource A + Data from resource B)
// Both resources are closed in reverse order (B then A)

State Monad

import { State } from "@chris5855/scats";

// Simple counter using State monad
const increment = State.modify<number>((n) => n + 1);
const getCount = State.get<number>();

// Combining state operations
const counter = increment
  .flatMap(() => increment)
  .flatMap(() => increment)
  .flatMap(() => getCount);

const [count, finalState] = counter.run(0);
// count: 3, finalState: 3

// More complex example: implementing a stack
type Stack = number[];

// Define stack operations
const push = (n: number) => State.modify<Stack>((stack) => [...stack, n]);
const pop = State.modify<Stack>((stack) => {
  const newStack = [...stack];
  newStack.pop();
  return newStack;
});
const peek = State.gets<Stack, number | undefined>(
  (stack) => stack[stack.length - 1]
);

// Use the operations in a computation
const stackOperations = push(1)
  .flatMap(() => push(2))
  .flatMap(() => push(3))
  .flatMap(() => peek)
  .flatMap((top) => pop.map(() => top));

const [topValue, resultStack] = stackOperations.run([]);
// topValue: 3, resultStack: [1, 2]

// Using eval and exec
const result = State.of<number, string>("hello").eval(42); // "hello"
const newState = State.put<number>(100).exec(42); // 100

Writer Monad

import { Writer, Monoids } from "@chris5855/scats";

// Simple logging with Writer monad
const logNumber = (n: number) =>
  Writer.tell<string>(`Processing ${n}`).flatMap(
    () => Writer.of(n * 2, Monoids.string),
    Monoids.string
  );

const result = logNumber(5).flatMap(
  (n) =>
    Writer.tell<string>(`Result is ${n}`).flatMap(
      () => Writer.of(n, Monoids.string),
      Monoids.string
    ),
  Monoids.string
);

const [value, logs] = result.run();
// value: 10, logs: "Processing 5Result is 10"

// Using array logs for structured logging
type StringArray = string[];

const add = (n: number) => Writer.withArray<string, number>(n, [`add(${n})`]);

const calculation = add(5)
  .flatMap(
    (n) =>
      add(10).flatMap(
        (m) => Writer.withArray<string, number>(n + m, [`sum(${n}, ${m})`]),
        Monoids.array<string>()
      ),
    Monoids.array<string>()
  )
  .flatMap(
    (n) => Writer.withArray<string, number>(n * 2, [`double(${n})`]),
    Monoids.array<string>()
  );

const [calcResult, calcLogs] = calculation.run();
// calcResult: 30, calcLogs: ['add(5)', 'add(10)', 'sum(5, 10)', 'double(15)']

🤝 Contributing

  • Fork it!
  • Create your feature branch: git checkout -b my-new-feature
  • Commit your changes: git commit -am 'Add some feature'
  • Push to the branch: git push origin my-new-feature
  • Submit a pull request

👥 Credits


💢 Troubleshootings

This is just a personal project created for study / demonstration purpose and to simplify my working life, it may or may not be a good fit for your project(s).


❤️ Show your support

Please ⭐ this repository if you like it or this project helped you!
Feel free to open issues or submit pull-requests to help me improving my work.

Buy Me A Coffee PayPal


🤖 Author

Chris M. Perez

You can follow me on github · twitter


Copyright ©2025 scats.