Skip to content

About

Exploring typeclasses in TypeScript, uses a bunch of hacks.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Typeclasses

Haskell-style typeclasses for TypeScript. Write an algorithm once and run it in every context you have — arrays, optional values, maps, sets, lazy iterables, async work, or a type you defined this morning.

Runs on Deno, Node, Bun, and browsers. The library core has no dependencies — every import inside src/ is relative, and a portability check enforces it. Published on JSR as @mewhhaha/typeclasses. (The optional build-time transformer under /transform uses Oxc's native TypeScript parser and esrap's TypeScript printer. It runs during the build.)

Foo<T> names a wrapped value with dictionary operations; RawFoo<T> names its .value() representation. For example, use Task<number> for a Task value and RawTask<number> for its deferred function. The same rule applies to ArrayT, List, Reader/State/Writer, and platform wrappers.

Why

TypeScript makes you pick a container and then rewrite your logic for every other container. Summing an array, summing a Map, and summing a number that might be missing are the same line of arithmetic wearing three signatures. Generics let you abstract over the element; nothing in the language lets you abstract over the container. So you write the fourth overload, or you give up and Array.from(...) everything into memory.

This library gives you the missing half.

import {
  array,
  type Data,
  Foldable,
  Just,
  map,
  Nothing,
  set,
  type type_element,
} from "@mewhhaha/typeclasses";

function total<
  dictionary extends Foldable<dictionary> & {
    readonly [type_element]?: number;
  },
>(
  values: Data<dictionary, number>,
): number {
  return Foldable.fold(values, 0, (running, value) => running + value);
}

total(array.from_array([1, 2, 3])); // 6
total(map.from_entries([["a", 1], ["b", 2]])); // 3
total(set.from_set(new Set([1, 2, 3]))); // 6
total(Just(42)); // 42
total(Nothing<number>()); // 0

One implementation, five containers, full inference, no casts and no overloads.

The part that matters is what is not in that list. total also works on a type you define yourself, because a typeclass is an open contract rather than a closed union. Most functional libraries hand you their Option and their Either and their combinators; when your domain type doesn't fit, you convert into theirs and back. Here you teach your own type to fold, and every Foldable function in the library — plus every one you wrote — starts working on it. The library never has to have anticipated your type.

That extends to types nobody owns. Map, Set, FormData, URLSearchParams, ArrayBuffer, ReadableStream, and typed arrays all ship with instances, so the platform's data shapes participate in the same vocabulary as your own.

Three more reasons

Collect every error, not just the first one. Promise and Either stop at the first failure, which is right for sequential steps and wrong for a form. Validation combines independent results and keeps all their errors, using the combining rule you supply.

Describe effects, then decide separately what they mean. A Program declares the capabilities it needs — configuration, state, logging, async, or an effect you define — in its type. Handlers discharge them one at a time. The same program can run against a real database or an in-memory one with no change to the program itself, which makes the interesting half of your code testable without mocks. This replaces monad transformer stacks: capabilities compose as a union of requirements instead of nesting into ReaderT over StateT over TaskEither, and the type stays readable at five capabilities.

Pay for the ergonomics at build time. Generator-based Do and Program blocks read like ordinary code, but interpreting a generator at runtime costs something. The bundled source transformer lowers them to direct method chains during your build. The benchmarks compare the generated modules with runtime interpretation and check their behavior before timing. Plugins for esbuild and Rolldown ship with the package.

What it is not

This is a small, inspectable library, not a framework. There is no scheduler, no fiber runtime, no dependency-injection container, and no structured-concurrency supervisor. If you want those, Effect is the mature choice and is a genuinely different product. Reach for this one when you want the typeclass abstraction itself — open to your types, with a readable implementation you can follow end to end.

It is pre-1.0. The API still moves between minor versions; see Migration Notes.

Quick Start

Install from JSR with the command for your runtime:

deno add jsr:@mewhhaha/typeclasses
npx jsr add @mewhhaha/typeclasses
bunx jsr add @mewhhaha/typeclasses

Node projects must use ESM. After installation, the same package import works in all three runtimes. This example validates independent fields and accumulates both errors instead of stopping at the first one:

import { Applicative, Invalid, Validation } from "@mewhhaha/typeclasses";

const Errors = Validation.with_semigroup<readonly string[]>({
  concat: (left, right) => [...left, ...right],
});

function required(field: string, value: string) {
  const trimmed = value.trim();

  return trimmed === ""
    ? Errors.Invalid([field + " is required"])
    : Errors.Valid(trimmed);
}

const request = Applicative.lift(
  (name, email) => ({ name, email }),
  required("name", ""),
  required("email", ""),
);

const result = request.value();

if (Invalid.is(result)) {
  result[1]; // ["name is required", "email is required"]
}

A program that needs configuration, logging, and async work declares all three in its type, then has them handled one at a time:

import { array, Effect, Program, type Uses } from "@mewhhaha/typeclasses";
import { ask, type AsReader, run_reader } from "@mewhhaha/typeclasses/reader";
import { type AsWriter, run_writer, tell } from "@mewhhaha/typeclasses/writer";
import { type AsTask, from_fn, run_task } from "@mewhhaha/typeclasses/task";
import type { AsArray } from "@mewhhaha/typeclasses/array";

type Config = { readonly base: string };

const App = Program.scope<
  | Uses<AsReader<Config>>
  | Uses<AsWriter<AsArray, string>>
  | Uses<AsTask>
>();

const fetch_title = App(function* () {
  const config = yield* ask<Config>();

  yield* tell(array.ArrayT([`GET ${config.base}/title`]));

  const body = yield* from_fn(async () => "Hello");

  return body.toUpperCase();
});

const [value, logs] = await Effect.interpret(fetch_title)
  .handle((effect) => run_reader(effect, { base: "https://example.com" }))
  .handle((effect) => run_writer(effect, array.ArrayT<string>([])))
  .run(run_task);

value; // "HELLO"
array.to_array(logs); // ["GET https://example.com/title"]

Swap the last .run(run_task) for a different runner, or the from_fn step for a custom effect, and the program body stays untouched. That is the whole argument for effects over transformer stacks.

Choosing a type

Pick the smallest abstraction that expresses the behavior:

Need Start with
Optional value Maybe
One success or one failure Either
Accumulating independent validation errors Validation
Deferred asynchronous work Task
Dependent steps in one context Do
Reader, State, Writer, Task, or custom effects Program and Effect
Typed failure that short-circuits a program fail and run_except
Synchronous isolate-local transactions Stm
CPU work in runtimes with Web Worker globals worker_map or a reusable pool

Typeclasses and instances

The library provides these typeclass definitions:

  • Functor for map
  • Applicative for pure and ap
  • Monad for bind and Do
  • Foldable for fold
  • Traversable for flipping structures through an applicative
  • Ord for ordered comparisons
  • Semigroup and Monoid for appendable/empty structures
  • Alternative for empty/fallback list-like contexts
  • Bifunctor, Contravariant, and Profunctor for multi-variance mapping
  • Category and Arrow for composable function-like contexts
  • Comonad for extracting and extending contextual values
  • MonadError for monads with recoverable failures
  • Parse for parser-like values that can consume string input
  • Show and Eq as small utility typeclasses

Core instance coverage is intentionally visible:

Data dictionary Principal instances
Maybe Monad, Alternative, Traversable, Ord, first-biased Monoid
Either MonadError, Traversable, Bifunctor, Ord
Validation accumulating Applicative, Traversable, Ord
Task sequential Applicative, Monad, MonadError; concurrent application through ParallelTask
Fn Reader-style Monad, Profunctor, Category, Arrow, Parse
Tuple Bifunctor, Traversable, Comonad, Ord; writer Monad through with_monoid
Identity, List, ArrayT the expected identity and list-like instances

The JavaScript-shape wrappers — RecordT, MapT, SetT, IterableT, AsyncIterableT, TypedArrayT, DateT, and the rest — carry the instances listed under JavaScript Shapes.

Continue with one of these paths:

Package Entry Points

The root entrypoint contains core data types, typeclasses, Effect/Program, and the array, iterable, map, and set namespaces. Import optional runtimes and other wrappers from their focused entrypoints:

  • @mewhhaha/typeclasses for the common fluent core; /prelude for function-first helpers.
  • /typeclass and /typeclasses for dictionary machinery and definitions.
  • /maybe, /either, /validation, /identity, /fn, /tuple, /array, /predicate, /list, and /tagged for data values.
  • /map, /record, /set, /iterable, /async_iterable, /typed_array, /array_buffer, /data_view, /form_data, /url_search_params, /readable_stream, /date, /regexp, /error, /weak_map, and /weak_set for JavaScript shape wrappers and their conversions.
  • /task, /effects, /except, /reader, /state, /writer, /stm, and /parallel for effectful programs.
  • /quickcheck for deterministic generators, shrinking, property checks, and reusable typeclass laws.
  • /loop for stack-safe tail loops.
  • /transform and /transform/plugin for the source transformer and bundler adapters.

Deno can also use explicit jsr: specifiers, for example jsr:@mewhhaha/typeclasses/validation.

The four common collection namespaces keep conversion helpers such as from_entries and to_array unambiguous. Both of these work:

import { array } from "@mewhhaha/typeclasses";
import { ArrayT, to_array } from "@mewhhaha/typeclasses/array";

array.ArrayT([1, 2, 3]);
ArrayT([1, 2, 3]);

ArrayT is therefore not a bare root export; import { ArrayT } from "@mewhhaha/typeclasses" fails. The same applies to RecordT, MapT, SetT, IterableT, AsyncIterableT, and the other shape wrappers listed under Built-In Shapes. Later examples that write a bare ArrayT assume the granular import above.

The names that are bare root exports for list-like work belong to List, the recursive list: root from_array and to_array build and drain a List, not an ArrayT.

Runtime Support

Surface Deno Node Bun Browsers / workerd
Data types, typeclasses, prelude CI CI CI CI smoke tests
Task CI CI CI CI smoke tests; standard promises and AbortSignal
Web Worker pool CI No global Worker Requires compatible Web Worker globals Browser workers only; workerd has no pool support
Transformer CLI Deno — — Build-time only
Bundler adapters CI with esbuild and Rolldown Bundler host Bundler host Build-time only

Importing the root package does not start workers. Calling /parallel worker operations requires Worker, navigator, AbortController, and explicit resource-management support in the host. Node's worker_threads API is not a drop-in implementation, so Node applications should keep CPU work behind their own adapter or use Task for asynchronous work on the main isolate.

Migration Notes

In 0.15.0:

  • Wrapped public types consistently use Foo<T>; raw values use RawFoo<T>.

  • Declare const identity = Symbol("Name") and pass it to data<AsName>(identity). The type and runtime dictionary share that identity.

  • Nonempty collect({ id: Just(1), name: Just("Ada") }) infers its dictionary. Empty shapes still use collect(Maybe, {}).

  • Individual classes have /typeclasses/functor-style entry points. Typed operation tables and named cell results are available through /handlers.

  • Hills60–130 records outcomes and rejected experiments.

  • In 0.14.0, the API uses Maybe<T>, Either<E, T>, and Validation<E, T> for wrapped values. Use RawMaybe, RawEither, and RawValidation for tuples crossing a .value() boundary.

  • Cells now require their key at runtime: state<"count", number>("count"). Reusing a key within a family addresses the same cell; a unique const symbol creates a private address.

  • Use lift for every arity and concat for semigroup combination. The lift_A aliases and append were removed. Examples are no longer exported from the root library entry point.

  • Both prelude traversal helpers use (fn, dictionary, value); traverse_ now matches traverse.

  • collect(Maybe, { id: Just(1), name: Just("Ada") }) preserves record and tuple inference. get_or_else and recover_or_else evaluate their fallback lazily. bind_widen explicitly combines Either error types.

  • Keep Do for generator notation and branching. A measured Do_once prototype stayed under bench/experiments: eight-yield Maybe improved 10.7%, while Either slowed 12.3%, missing the half-runtime target. Use traverse_sequential when later callbacks must not run after failure.

  • run_task executes already-completed pure steps in the same turn. Use an asynchronous Task when a scheduling boundary is required.

  • Lazy iterables provide bounded preview, async filter and take, and ordered map_concurrent. traverse_task bounds Task admission. Stream iteration cancels on early exit unless preventCancel is set.

  • The 60-hill ledger records measurements, API changes, rejected experiments, and qualification limits.

  • In 0.13.0, iterable.from_array supplies a live array view, with lazy filter and take helpers. array.map_in_place and array.map_into make buffer updates explicit. The source plugin fuses supported direct lazy map chains; ArrayT.map keeps its immutable behavior.

  • In 0.12.0, Either.Left<error, item> and Validation.Invalid<error, item> use the same generic argument order as their standalone aliases. A single explicit argument selects the error type. Use both arguments to select a successful item, or configure the error once with Either.with_left<error>().Left<item>(error) or Validation.with_semigroup(semigroup).Invalid<item>(error).

  • Task.ap and Applicative.lift on Tasks now run sequentially, in input order, matching bind. Wrap independent inputs with parallel(task) to use the applicative-only ParallelTask dictionary. Use sequential(result) when the combined task needs to enter a Do block or use MonadError recovery.

  • Writer cells now require an empty-log witness: writer_cell<"audit", AsArray, string>("audit", ArrayT<string>([])). Their dictionaries support pure and yield-free Do blocks without a dummy Writer value.

  • await async_iterable.from_async_iterable(source) snapshots a finite async source for replay. Use from_factory to keep a replayable source lazy.

  • Byte and entry wrappers infer their actual folded element type. Typed-array constructors distinguish numeric arrays from BigInt arrays; generic type arguments cannot change the runtime element type. as_data checks typed dictionaries' raw shapes even through its object-dictionary overload.

  • The transformer diagnoses captured mutable state across yields and arbitrary for...of sources. Use the runtime generator with an explicit preserve policy when its state or iteration cannot be lowered safely.

  • Collection traversal calls its callback in input order and materializes each result once. Deferred execution follows the selected applicative: Task is sequential and ParallelTask starts independent work together.

  • Task cancellation is now supplied at execution with .run(signal) or run_task(effect, { signal }), so it propagates through the whole composed computation. from_fn and from_promise no longer accept constructor options. Use run_task_exit when cancellation should be returned as a value.

  • Deprecated camel-case APIs and short transformer-plugin aliases have been removed. Transformer plugins now fail unsupported recognized syntax by default; use on_unsupported: "warn" or "preserve" for an explicit fallback policy.

  • Root imports for Task, STM, worker pools, Except, Reader, State, and Writer move to their focused subpaths. Shape namespaces other than array, iterable, map, and set also use focused subpaths.

  • The prelude's then is now sequence_right. Exporting then makes the module namespace thenable, so dynamic imports try to call it instead of resolving to the module.

  • Generic type parameters formerly named out are now named output so Bun and esbuild can parse the published TypeScript. Type parameter names are not part of call-site syntax, so this requires no application change.

  • Custom dictionary interfaces must give As an explicit nominal identity. Declare a module-local unique symbol and pass its type as the second argument, as in As<AsExample, typeof example_identity>. This prevents two dictionaries with identical raw shapes from being mixed accidentally.

  • Validation no longer implements Bifunctor: changing the error type cannot lawfully preserve its error semigroup. Replace error-side bimap calls with map_error(validation, fn, target_semigroup), then use .map(...) for the success side. Use Validation.with_semigroup(semigroup) when several custom errors must accumulate.

  • The public API uses snake-case names consistently, including with_left, with_monoid, and map_error.

Development

Run the complete release gate, including coverage and mutation checks, transformer bundles, package entrypoints, Deno, Node, Bun, browser, and workerd runtime smokes, examples, the tutorial, and case studies:

deno task verify

verify requires Deno, Node, Bun, and Chrome, Chromium, or Firefox. Run deno task prepublish to add the JSR dry run. The CI workflow runs both. Benchmark timings run separately; equivalence and scaling preflights are part of verification:

deno task bench

The comparison benchmarks use pinned npm packages through deno.json imports, so the first run may download fp-ts, effect, purify-ts, and true-myth.

Publish checks intentionally do not use --allow-slow-types. Repository source therefore sometimes spells out exported constructor and return types that an application would normally infer. Public exports need stable declaration output; app-local definitions do not.

The package is MIT licensed.

Shape

The core wrapper and typeclass-definition machinery lives in src/typeclass.ts and src/data_value.ts. Application-level typeclass definitions live in src/typeclasses/, with src/typeclasses.ts re-exporting them for examples.

Each data type exports a type and a same-named function. The function wraps an existing contextual value as a fluent WrappedData<dictionary, value, item> and also acts as the data dictionary. Tuple-tagged data can use data(union(...)) to generate constructors like Just(...) and guards like Just.is(...) from the raw value shape.

Each data type declares its raw value shape directly on its dictionary interface with type-only phantom symbols. That maps the contextual item to the raw value the dictionary wraps, so Data<typeof Maybe, item> can recover that Maybe stores Maybe<item> without a global registry or a public kind symbol. TypeScript still has no native higher-kinded types, so this small open-HKT encoding and its type_item/type_data symbols remain part of dictionary definitions even though most callers never mention them.

In application code, you usually do not need to write the export-safe annotations used inside this library. A local data type can be left mostly inferred:

Typeclass instance methods receive the wrapped value as this. The installer stores that this-based instance in the canonical symbol slot and exposes direct fluent aliases like .show() and .map(). The canonical typeclass slot is a unique symbol, which keeps implementations separate. Direct fluent method names must be unique on a dictionary; installing a conflicting alias throws.

import {
  $slot,
  type As,
  data,
  type type_data,
  type type_item,
  union,
} from "@mewhhaha/typeclasses/typeclass";
import { Monad, Show } from "@mewhhaha/typeclasses/typeclasses";

type RawMaybe<item> =
  | readonly ["Just", item]
  | readonly ["Nothing"];

const maybe_identity = Symbol("Maybe");

interface AsMaybe
  extends As<AsMaybe, typeof maybe_identity>, Show<AsMaybe>, Monad<AsMaybe> {
  readonly [type_item]: unknown;
  readonly [type_data]: RawMaybe<this[typeof type_item]>;
}

const Maybe = data<AsMaybe>(
  maybe_identity,
  union(["Just", $slot], ["Nothing"]),
);
const Just = Maybe.Just;
const Nothing = Maybe.Nothing;

Show.instance(Maybe)({
  show() {
    const maybe = this.value();

    if (Nothing.is(maybe)) {
      return "Nothing";
    }

    return "Just(" + maybe[1] + ")";
  },
});

Monad.instance(Maybe)({
  bind(fn) {
    const maybe = this.value();

    if (Just.is(maybe)) {
      return fn(maybe[1]);
    }

    return Nothing();
  },
});

const raw = Just(42).value();

if (Just.is(raw)) {
  raw[1]; // number
} else if (Nothing.is(raw)) {
  raw[0]; // "Nothing"
}

The declaration only lists the strongest required capabilities. Superclasses are transitive: Monad includes Applicative and Functor, Traversable includes Functor and Foldable, and Ord includes Eq, so those interfaces do not need to be repeated on AsMaybe. Each superclass implementation still needs to be installed; the shorter heritage list removes duplicate typing, not runtime instance definitions.

Haskell-style minimal complete definitions remove the common superclass boilerplate:

Typeclass Define Derived automatically
Applicative pure, ap map
Monad pure, bind map, ap
Ord compare eq

Use Applicative.derive(dictionary)(minimal), Monad.derive(dictionary)(minimal), or Ord.derive(dictionary)(minimal). Constructors should be closed over inside pure; instance-method this is a wrapped value, not the callable dictionary. A later Functor.instance(...) or Applicative.instance(...) replaces a derived method, so a type can derive first and then install a hot-path specialization.

Monad.derive(Maybe)({
  pure(value) {
    return Just(value);
  },
  bind(fn) {
    return this.match({
      Just: fn,
      Nothing: () => Nothing(),
    });
  },
});

In this repository's source you may still see exported constructor types such as MaybeConstructor = UnionDictionary<AsMaybe>. Those are declaration-friendly annotations for publishing without --allow-slow-types; they are not part of the normal application authoring style.

See src/maybe.ts, src/either.ts, src/identity.ts, src/predicate.ts, src/fn.ts, src/tuple.ts, src/list.ts, src/task.ts, src/array.ts, src/map.ts, and src/record.ts for complete examples.

Fluent API

Wrapped values chain directly through the implemented typeclasses:

const sum = Just((left: number) => {
  return (right: number) => left + right;
})
  .ap(Just(20))
  .ap(Just(22));

sum.value(); // ["Just", 42]
sum.eq(Just(42)); // true

If the wrapped raw value is a function, the wrapper also exposes .run(...) as the typed shortcut for .value()(...). That keeps callable data types such as Fn, Task, Reader, State, and IterableT from leaking double calls into examples.

Wrapped values stay ordinary JavaScript objects. String contexts render through the Show instance when the dictionary has one, and JSON.stringify emits the raw value so the tag survives a round trip:

String(Just(1)); // "Just(1)"
`${Nothing()}`; // "Nothing"
JSON.stringify(Just(1)); // '["Just",1]'
Maybe(JSON.parse('["Just",1]')); // the wrapped value again

toString and toJSON are reserved by the wrapped-data protocol alongside match, so custom typeclass instances should not reuse those method names.

The same-named function wraps an existing raw context when you already have one:

const doubled = Maybe(sum.value()).map((value) => value * 2);

The public data-wrapped value protocol is WrappedData<dictionary, value, item>. Most code can use the shorter Data<dictionary, item> helper:

type WrappedMaybe<item> = Data<typeof Maybe, item>;

data<AsMaybe>(maybe_identity) creates the callable dictionary, uses the explicit identity as its runtime kind, and routes calls through a cached constructor. The lower-level as_data(dictionary, value) and as_data_cached(dictionary) helpers remain available for integrations that need to manage construction directly.

Each data type declares its raw value shape once on the As... interface. Data uses that shape to type helper functions, instance methods, and fluent methods.

For a shape with a fixed folded element, declare [type_element] alongside [type_item] and [type_data]. Byte wrappers use number; form and URL wrappers use their entry tuples. DataItem<dictionary, item> resolves that associated element so generic folds cannot change it by choosing a different type argument. Ordinary containers continue to use their generic item slot.

The wrapped value's prototype points at a shared data prototype, which delegates to the dictionary. Symbol-scoped implementations and direct fluent aliases are inherited through that prototype. Since implementations are this-based, the fluent aliases can use the instance functions directly.

Data type modules use the same callable dictionary for public wrapping and their own constructors.

Show implementations use the library's runtime-neutral structural inspector. It delegates to the host inspector when Deno provides one and otherwise renders primitives, collections, objects, special numeric values, and cycles deterministically, so calling .show() or the Ord fallback does not require a Deno global in Node, Bun, browsers, or workerd.

Each data type exports an open dictionary interface. Typeclass instances use the curried Typeclass.instance(dictionary)(implementation) form when generic methods need contextual typing. The first call fixes the dictionary before TypeScript checks the implementation's higher-rank methods. Typeclass definitions are prototype-backed objects made with typeclass; each definition binds the shared installer, instance accessor, and derived operations once, while public helper methods keep typeclass-specific types. Their bodies usually dispatch through call_typeclass_method. Outside the declaring module, extend a dictionary by augmenting its exported As... interface and installing the implementation on the callable dictionary.

Implementation methods usually do not need explicit generic parameters. Collection traversal builds a balanced immutable tree, then materializes each applicative result once. Callbacks run in input order, branch results never share a mutable accumulator, and applicative nesting has logarithmic depth. Empty inputs use the supplied dictionary's pure. traverse_ also combines discarded results at logarithmic depth, so large Reader, State, and Task traversals can run without overflowing the call stack.

import {
  call_typeclass_method,
  type Data,
  type Dictionary,
  typeclass,
  type TypeclassDictionary,
} from "@mewhhaha/typeclasses/typeclass";
import { type AsList, List, to_array } from "@mewhhaha/typeclasses/list";

const size_typeclass = Symbol("Size");

interface Size<dictionary extends Dictionary> extends
  TypeclassDictionary<
    dictionary,
    typeof size_typeclass,
    {
      size: <item>(this: Data<dictionary, item>) => number;
    }
  > {}

const Size = typeclass(size_typeclass, {
  size<
    dictionary extends Size<dictionary>,
    item,
  >(value: Data<dictionary, item>) {
    return call_typeclass_method(this.instance_for(value).size<item>, value);
  },
});

declare module "@mewhhaha/typeclasses/list" {
  interface AsList extends Size<AsList> {}
}

Size.instance(List)({
  size() {
    return to_array(this).length;
  },
});

There is no MaybeBox or MaybeInstance type. The fluent methods are derived from the dictionary shape plus the wrapped value and item type.

Exported data constructors keep the fluent methods:

const parsed = Right("42").bind((text) => {
  return from_number(Number.parseInt(text, 10));
});

parsed.value(); // ["Right", 42]

Typeclass operations can also be passed around or destructured. They retain their dispatcher and infer the item type from the wrapped value:

import { Just } from "@mewhhaha/typeclasses/maybe";
import { Functor } from "@mewhhaha/typeclasses/typeclasses/functor";
import { fmap } from "@mewhhaha/typeclasses/prelude";

const fluent = Just(41).map((value) => value + 1);
const { map } = Functor;
const generic = map(Just(41), (value) => value + 1);
const function_first = fmap((value) => value + 1, Just(41));

Either does not fix the error payload to string; Left(value) keeps the error value's type. The examples use strings because they are easy to inspect. Left and Right are typed constructor exports over the Either dictionary, so callers do not need casts to preserve the left payload type. Either.Left, Either.Right, Validation.Valid, and Validation.Invalid are the corresponding standalone constructors, including their guards and inference.

Fluent Right(...).map(...).bind(...) chains retain the error type introduced by the dependent step. For generic operations that need a fixed error type, select Either.with_left<error>(). A failure with no success item can infer its recovery result: Left("missing").catch_error(error => Right(error.length)). Recovery of an existing success keeps that success type.

from_nullable(value) produces Maybe<NonNullable<typeof value>>, so a map callback receives a non-null payload even when the input type includes null or undefined.

Fixed context parameters are part of the open dictionary too. AsEither<left>, AsTuple<left>, AsValidation<error>, and AsFn<input> keep their fixed parameter while the type_item slot varies, so typeclass operations no longer erase it to unknown:

const parsed: Data<AsEither<string>, number> = Right<string, number>(42);
const counted: Data<AsTuple<string>, number> = tuple("count", 42);
const StringErrors = Validation.with_semigroup<readonly string[]>({
  concat: (left, right) => [...left, ...right],
});
const checked: Data<AsValidation<readonly string[]>, number> = StringErrors
  .Invalid<number>(["missing"]);

const StringResult = Either.with_left<string>();
const returned: Data<AsEither<string>, number> = Do(
  StringResult,
  function* () {
    return 42;
  },
);

const named: Data<AsFn<{ readonly name: string }>, string> = fn(
  (text: string) => text.length,
).dimap(
  (user) => user.name,
  (length) => "length:" + length,
);

named.run({ name: "Ada" });

Tuple.with_left<left>() and Validation.with_error<error>() provide the same typed dictionary view when a context-free operation needs the fixed parameter; Fn.with_input<input>() provides one for function dictionaries. Prefer Validation.with_semigroup(semigroup) when code also constructs failures, since the returned dictionary carries the accumulation rule. Bifunctor, Profunctor, Category, and Arrow carry a small associated context mapping so operations that change a fixed parameter return the corresponding new dictionary type.

Function Prelude

Import ./prelude when function-first operations read more naturally than fluent chains. The functions dispatch through the same dictionaries and wrapped values; this is an additional surface, not a second implementation:

import { ArrayT } from "jsr:@mewhhaha/typeclasses/array";
import { Just, Maybe, Nothing } from "jsr:@mewhhaha/typeclasses/maybe";
import {
  ap_first,
  ap_second,
  bind,
  empty,
  fmap,
  fold_map,
  foldl,
  from_maybe,
  guard,
  join,
  lift,
  mempty,
  pure,
  sum,
  traverse,
  voided,
} from "jsr:@mewhhaha/typeclasses/prelude";

const answer = pure(Maybe, 41);
const incremented = fmap((value) => value + 1, answer);
const rendered = bind(incremented, (value) => Just(value.toString()));
const total = foldl((sum, value) => sum + value, 0, ArrayT([1, 2, 3]));
const checked = traverse((value) => Just(value + 1), Maybe, ArrayT([1, 2]));
const flattened = join(Just(Just(42)));
const combined = lift((left, right) => left + right, Just(20), Just(22));
const defaulted = from_maybe(0, Nothing<number>());

empty(Maybe); // Nothing
mempty(ArrayT); // []

The prelude keeps familiar Haskell vocabulary where JavaScript syntax allows it:

Area Functions
Functor / applicative / monad fmap, pure, ap, lift, join, voided, when, unless, guard, ap_first, ap_second, sequence_right, bind
Folding / traversal foldl, fold_map, mconcat, to_array, length, sum, product, elem, traverse, traverse_, sequence
Maybe / Either elimination from_maybe, maybe, to_nullable, to_either, either, from_left, from_right, hush, note
Utility typeclasses show, eq, ordering helpers, concat, mempty, alt, empty, throw_error

voided is the spelling of Haskell's void because void is a JavaScript operator. ap_first and ap_second correspond to <* and *>. sequence_right replaces Haskell's then, which makes a module thenable when exported from JavaScript.

lift(fn, first, ...rest) accepts any nonempty number of contextual inputs, with each callback parameter inferred from its corresponding input. when and unless accept ordinary effects returning void, skip inactive actions, and return an undefined item in either branch.

The consumer ergonomics example compares fluent, detached typeclass, and prelude calls, then uses configured dictionaries and deferred Tasks without casts.

Configured dictionary factories follow the same convention, including Either.with_left and Tuple.with_monoid.

Tagged Values

For a new union, one schema can define both the tuple shape and constructors:

import { $slot, define_union, field } from "@mewhhaha/typeclasses/typeclass";

const outcome_id = Symbol("Outcome");
const Outcome = define_union(outcome_id, ["Ok", $slot], [
  "Failed",
  field<string>(),
]);
type Outcome<T> = ReturnType<typeof Outcome.Ok<T>>;

const result: Outcome<number> = Outcome.Ok(42);
result.match({ Ok: (value) => String(value), Failed: (error) => error });

$slot is the generic item; field<T>() is a fixed payload. The identity must be one unique const symbol. Duplicate, colliding, and reserved constructor names fail immediately. This helper builds constructors and guards; attach typeclass instances explicitly when the data type needs them. See the three schema examples.

Data constructors use tuple tags. Deconstruct the raw value and switch on the tag when you want direct branching:

const [tag, value] = Just(42).value();

switch (tag) {
  case "Nothing":
    value; // undefined
    break;
  case "Just":
    value; // number
    break;
}

For expression-style branching, use the match helper. The handler record must cover every tag in the value:

const label = match(Just(42), {
  Nothing: () => "missing",
  Just: (value) => "value:" + value.toString(),
});

Tagged wrapped values expose the same exhaustive operation fluently, so a chain does not need to return to standalone syntax:

const label = Just(41)
  .map((value) => value + 1)
  .match({
    Just: (value) => "value:" + value.toString(),
    Nothing: () => "missing",
  });

The cases are inferred from the wrapped raw tuple and are exhaustive for Maybe, Either, Validation, List, and custom tagged unions. Non-tagged wrappers such as Task and Fn do not expose a callable match type. match is reserved by the wrapped-data protocol, so custom typeclass instances should not reuse that direct method name.

Every generated constructor also exposes a branch-specific guard. Prefer these guards when one branch needs special handling and the narrowed tuple remains useful. Use match or an exhaustive switch when every branch contributes to one result.

const result = from_number(Number.parseInt("42", 10)).value();

if (Left.is(result)) {
  result[1]; // string
} else if (Right.is(result)) {
  result[1]; // number
}

const optional = Just(42).value();

if (Nothing.is(optional)) {
  optional[0]; // "Nothing"
} else if (Just.is(optional)) {
  optional[1]; // number
}

const checked = InvalidMessages<number>("missing").value();

if (Invalid.is(checked)) {
  checked[1]; // readonly string[]
} else if (Valid.is(checked)) {
  checked[1]; // number
}

Each guard narrows its false branch as well, so a two-variant value can often use a final plain else after the first check.

Do is generator-based do notation. It runs a generator over one monad dictionary and uses yield* to bind each wrapped value. Pass the dictionary explicitly when you want the monad to be visible at the call site:

const incremented = Do(Maybe, function* () {
  const number = yield* Just(42);

  return number + 1;
});

incremented.value(); // ["Just", 43]

const answer = Do(Maybe, function* () {
  return 42;
});

answer.value(); // ["Just", 42]

The explicit dictionary makes yield-free blocks possible because Do can call its pure. The original Do(function* () { ... }) form remains available when the first yielded value can supply the dictionary.

Runtime Do replays a generator for every branch of a multi-shot monad such as List or ArrayT. Code before a yield can therefore run more than once, and a block with many yields has replay overhead. Keep side effects out of runtime Do blocks; the build-time transformer below lowers supported blocks to direct chains and avoids replay.

Task shows the same typeclass shape for deferred async work:

const greeting = Do(function* () {
  const id = yield* from_fn(async () => 7);
  const name = yield* from_fn(async () => "Ada #" + id);

  return "hello " + name;
});

await greeting.run(); // "hello Ada #7"

For mixed capabilities, use effects. Program.scope<Allowed>() creates a typed boundary for the operations a Program block may yield. The block can still run a too-wide nested effect locally to make it fit the scope:

type Label =
  | Uses<AsReader<LabelConfig>>
  | Uses<AsTask>;
type App =
  | Uses<AsReader<Config>>
  | Uses<AsState<number>>
  | Uses<AsWriter<AsArray, string>>
  | Uses<AsTask>;

const Label = Program.scope<Label>();
const App = Program.scope<App>();

const label = Label(function* () {
  const config = yield* ask<LabelConfig>();
  const suffix = yield* from_fn(async () => ":async");

  return config.label + suffix;
});

const program = App(function* () {
  const config = yield* ask<Config>();
  const before = yield* get<number>();
  const scoped_label = yield* run_reader(label, { label: config.label });

  yield* modify((value: number) => value + config.increment);
  yield* tell(ArrayT([scoped_label + ":" + before.toString()]));

  return yield* get<number>();
});

await Effect.interpret(program)
  .handle((effect) => run_reader(effect, { label: "step", increment: 2 }))
  .handle((effect) => run_state(effect, 40))
  .handle((effect) => run_writer(effect, ArrayT<string>([])))
  .run(run_task);

Build-Time Transform

The package also exports the source transformer as ./transform:

The transformer uses Oxc's TS-ESTree API. Deno callers grant --allow-env --allow-read --allow-ffi for its native loader; deno task transform includes these permissions. The library core needs no permissions. Type checking remains with Deno/TypeScript; Oxc parses and rewrites syntax without checking TypeScript types.

See the migration measurements for workload timings and verification details.

import { transform_do_program_source } from "@mewhhaha/typeclasses/transform";

const result = transform_do_program_source(source_text, "input.ts");

result.code; // transformed TypeScript source
result.diagnostics; // skipped unsupported patterns
result.transformed; // number of rewritten sites

Pass { source_map: false } as the third argument when the consumer does not need a source map. Maps remain enabled by default; the transform cache keeps these modes separate.

The transformer is meant for bundlers, build scripts, or local workflows that want the ergonomic Do(function* () { ... }), explicit Do(dictionary, function* () { ... }), and Program(function* () { ... }) syntax in source code, but cheaper raw bindings in emitted code. It currently lowers supported Do blocks to direct typeclass method chains, supported Program blocks to Effect.bind/Effect.map/ Effect.pure, return yield* value to the monadic right identity, and static Effect.handle_with(program, [handlers...]) calls to nested runner calls. The transform also lowers terminal run(run_reader(...)), run(run_state(...)), and run(run_writer(...)) calls to direct runners, and removes generated wrapper IIFEs where it can preserve evaluation order. An immediate, straight-line terminal Program(function* () { ... }) is fused further into direct Reader, State, or Writer steps, avoiding the intermediate Effect spine. Exact imports of the built-in ask/asks, State primitives, and writer/tell lower to their raw operations; mixed or delegated yields retain checked terminal dispatch. Named/reusable programs and generators with effectful control flow retain the general lowering.

The intrinsic tier assumes the built-in dictionaries' kind and iterator behavior have not been monkey-patched. Code that intentionally changes those runtime identities should not use the source transform for those calls.

The transformer also combines adjacent lazy IterableT maps into one mapper:

import { from_array } from "@mewhhaha/typeclasses/iterable";

const pipeline = from_array([1, 2, 3])
  .map((value) => value + 1)
  .map((value) => value * 10);

This removes intermediate iterable wrappers and generator layers while retaining lazy per-item callback order, replay, exceptions, and upstream iterator cleanup. Callbacks remain in their original lexical scope and are created once when the pipeline is built. The optimization recognizes direct calls to the built-in iterable constructors and helpers, including import aliases and namespaces, and known fluent operations such as filter and take. It assumes those built-in operations have not been replaced. Only unary arrow callbacks with expression bodies and simple parameters are fused. Named intermediate values, opaque callbacks, explicit map type arguments, and optional chains retain their existing behavior. Skipped map optimizations do not produce diagnostics.

Supported generator control flow includes if, non-fallthrough switch, classic for, while, do/while, and for...of over literal primitive arrays, including unlabeled break/continue. Array-loop let and const bindings keep their declaration semantics. Arbitrary iterables are diagnosed and retained so early exits keep their iteration and cleanup behavior. Function-scoped var locals also retain the generator. Captured locals mutated after a yield and local objects, functions, or classes shared across yields retain their runtime allocation and scope. Observable prefix work such as opaque calls or external mutation before a later yield is diagnosed because generator replay can repeat it across branches. Put observable work in the yielded context or choose the explicit runtime fallback. Do and Program generators containing try/catch are diagnosed and left unchanged because the syntax-only transformer cannot preserve both JavaScript exceptions and dictionary-specific monadic errors. Labeled jumps, switch fallthrough, for await, and try/finally stay unsupported.

Loop lowering uses named recursive binding functions. This preserves per- iteration let bindings, but very large strict-monad loops can still exhaust the JavaScript stack. Use MonadRec.tail_rec_m for generic monadic recursion or the standalone loop/rec/done API for pure unbounded iteration.

Detection is anchored to package imports, including aliases and namespace imports. Local functions that merely happen to be named Do or Program are not rewritten. Relative source imports in this repository are recognized, and other re-export specifiers can be supplied through library_specifiers. Facades that export run, the composable lift handlers, and all corresponding terminal runners can opt into terminal lowering through terminal_library_specifiers.

Unsupported shapes are left unchanged and reported through diagnostics. The repository task exposes the same tool on the command line; --check makes any diagnostic fail CI:

deno task transform --write src/file.ts
deno task transform --check src/file.ts

Files without possible transform targets, and parsed files that produce no rewrites, keep their original source text. This avoids needless AST construction and printing in build pipelines while preserving diagnostics for unsupported Do and Program calls.

Bundlers can use the adapters from ./transform/plugin:

import {
  typeclasses_esbuild_plugin,
  typeclasses_rolldown_plugin,
  typeclasses_rollup_plugin,
} from "@mewhhaha/typeclasses/transform/plugin";

const esbuild = typeclasses_esbuild_plugin();
const vite_or_rollup = typeclasses_rollup_plugin();
const rolldown = typeclasses_rolldown_plugin();

The adapters skip non-TypeScript files and sources without likely transform targets. Unsupported recognized syntax fails the build by default. Set on_unsupported to "warn" to keep supported rewrites while warning, or to "preserve" to return the whole file unchanged. The Rolldown adapter also exposes a native hook filter so skipped files do not cross into JavaScript. Transformed output carries a version 3 source map with the original TypeScript in sourcesContent. Rollup and Rolldown receive the map directly; the esbuild adapter appends it inline to the transformed source.

Tail Loops

Use loop(initial, step) for explicit tail recursion without growing the JavaScript call stack. The callback returns either done(value) or rec(nextState), so only tail calls are expressible:

const factorial = loop({ n: 6, acc: 1 }, ({ n, acc }) => {
  if (n <= 1) {
    return done(acc);
  }

  return rec({ n: n - 1, acc: acc * n });
});

Haskell Comparisons

The Haskell version usually starts from a typeclass constraint. In this repo the dictionary is carried by the wrapped value instead, so generic code accepts a Data<dictionary, item> and calls the exported typeclass helper.

Functor

fmap (+1) (Just 41)
fmap((value) => value + 1, Just(41));
Functor.map(Just(41), (value) => value + 1);
Just(41).map((value) => value + 1);

Applicative

(+) <$> Just 20 <*> Just 22
Applicative.lift(
  (left, right) => left + right,
  Just(20),
  Just(22),
);

Just((left: number) => {
  return (right: number) => left + right;
})
  .ap(Just(20))
  .ap(Just(22));

The applicative API is explicit TypeScript: use Applicative.lift when the inputs are independent, or fluent ap when you already have a contextual function. Parser-style applicatives can use the same shape by lifting a field constructor over independent parser values.

Operations that have no input value use a dictionary witness rather than a dummy wrapped value:

Maybe.pure(42);
Applicative.pure(Maybe, 42);
pure(Maybe, 42); // from ./prelude

Monad

parse input >>= validate >>= save
bind(bind(parse(input), validate), save);

parse(input)
  .bind(validate)
  .bind(save);

Generator Do is the closest equivalent to Haskell do notation:

do
  text <- parse input
  value <- validate text
  save value
Do(function* () {
  const text = yield* parse(input);
  const value = yield* validate(text);

  return yield* save(value);
});

Maybe and Either

safePort :: String -> Maybe Int
safePort text =
  readMaybe text >>= \port ->
    if port > 0 then Just port else Nothing
function read_port(text: string) {
  const port = Number.parseInt(text, 10);

  if (!Number.isInteger(port)) {
    return Nothing<number>();
  }

  return Just(port);
}

function safe_port(text: string) {
  return read_port(text)
    .bind((port) => {
      if (port > 0) {
        return Just(port);
      }

      return Nothing();
    });
}

Either fills the same role as a conventional Either error item:

decode input =
  parse input >>= validate
function decode(input: unknown) {
  return parse(input)
    .bind(validate);
}

Ord

compare (Just 2) (Just 1)
maximum [20, 42]
Ord.compare(Just(2), Just(1)); // "gt"
Ord.max(identity(20), identity(42)).value(); // 42

Ord extends Eq and returns "lt", "eq", or "gt". Maybe, Either, Validation, Identity, ArrayT, List, and RecordT implement the expected tag or lexicographic ordering.

Bifunctor and MonadError

bimap length (+1) (Left "missing")
catchError (Left "missing") (\error -> Right (length error))
Bifunctor.bimap(
  Left<string, number>("missing"),
  (message) => message.length,
  (value) => value + 1,
);

MonadError.catch_error(
  Left<string, number>("missing"),
  (error) => Right(String(error).length),
);

Either is the natural implementation for both typeclasses. Validation keeps error mapping explicit through map_error, which requires the destination semigroup instead of inventing one during bimap. Task implements MonadError by rejecting and recovering deferred promises.

Tuple

fmap (+1) ("count", 41)
bimap length (+1) ("count", 41)
fst ("count", 41)
const value = tuple("count", 41);

Functor.map(value, (item) => item + 1).value(); // ["count", 42]
Bifunctor.bimap(
  value,
  (label) => label.length,
  (item) => item + 1,
).value(); // [5, 42]

fst(value); // "count"
snd(value); // 41
swap(value).value(); // [41, "count"]

const Logged = Tuple.with_monoid(ArrayT<string>([]));
const logged = Logged([ArrayT(["start"]), 20] as const)
  .bind((item) => Logged([ArrayT(["finish"]), item + 22] as const));

logged.value()[0].value(); // ["start", "finish"]
logged.value()[1]; // 42

Tuple<left, right> stores a plain pair as readonly [left, right]. Its normal item slot is the right side, so Functor, Foldable, Traversable, and Comonad work over the second value just like Haskell's (,) left. Bifunctor maps both slots. Tuple.with_monoid(empty) creates the classic writer-monad dictionary, accumulating wrapped values in the left slot.

Contravariant

contramap score positive
const positive = predicate((value: number) => value > 0);
const positive_score = Contravariant.contramap(
  positive,
  (user: { readonly score: number }) => user.score,
);

positive_score.run({ score: 1 }); // true

Predicate is contravariant because mapping happens before the value reaches the predicate.

Comonad

extract (Identity 41)
extend (\wrapped -> extract wrapped + 1) (Identity 41)
const value = identity(41);

Comonad.extract(value); // 41
Comonad.extend(value, (wrapped) => wrapped.value() + 1).value(); // 42

const counted = tuple("count", 41);

Comonad.extract(counted); // 41
Comonad.extend(counted, (wrapped) => {
  const [label, item] = wrapped.value();

  return String(label) + ":" + item.toString();
}).value(); // ["count", "count:41"]

Identity is intentionally boring, which makes the laws easy to see. Tuple is the more practical instance: the left slot remains available as context while extend computes a new focused right slot from the whole pair.

Profunctor, Category, Arrow, and Parse

dimap name show length
arr (+1) >>> arr (*2)
first (arr (+1))
const named_length = Profunctor.dimap(
  fn((text: string) => text.length),
  (user: { readonly name: string }) => user.name,
  (length) => "len:" + length.toString(),
);

const composed = Category.compose(
  fn((value: number) => value * 2),
  fn((value: number) => value + 1),
);

const first = Arrow.first(fn((value: number) => value + 1));
const parsed = Parse.parse(
  fn((text: string) => Number.parseInt(text, 10)),
  "42",
);

Fn is the small function carrier for these typeclasses and also has the Reader-style applicative and monad: pure ignores the shared input, while bind gives both the produced value and that input to the next function. Use fn(...) or arr(...) to keep .run(...) typed. The higher-arity typeclasses are represented with raw-value generics because this library's core Data<dictionary, item> tracks one item slot, while Haskell's function-like classes are parameterized over both input and output.

List

do
  left <- [1, 2]
  right <- [10, 20]
  pure (left + right)
import { from_array } from "@mewhhaha/typeclasses/list";

Do(function* () {
  const left = yield* from_array([1, 2]);
  const right = yield* from_array([10, 20]);

  return left + right;
});

List is the recursive list implementation. ArrayT is the wrapper for native JavaScript arrays.

Foldable

foldl' (+) 0 [1, 2, 3]
foldl((sum, item) => sum + item, 0, ArrayT([1, 2, 3]));

Foldable.fold(
  ArrayT([1, 2, 3]),
  0,
  (sum, item) => sum + item,
);

Foldable stays the minimal core operation. The function prelude builds fold_map, mconcat, to_array, length, sum, product, elem, and traverse_ on top of it.

Traversable

traverse readMaybe ["1", "2", "3"]
sequenceA [Just 1, Just 2, Just 3]
import { from_number } from "@mewhhaha/typeclasses/either";

Traversable.traverse(
  array.ArrayT(["1", "2", "3"]),
  Either.with_left<string>(),
  (text) => from_number(Number.parseInt(text, 10)),
);

Traversable.sequence(
  array.ArrayT([Just(1), Just(2), Just(3)]),
  Maybe,
);

Either and Validation fix their error type through with_left / with_error, so traverse needs the configured dictionary rather than the bare one. Maybe has no fixed parameter and can be passed directly.

Traversable flips a container of effects into an effect containing a container. This is the same shape as Haskell's traverse and sequenceA, but the TypeScript version receives the target applicative dictionary explicitly. Representative wrapped values remain accepted for compatibility.

Semigroup and Monoid

[1, 2] <> [3]
mempty <> ["done"]
mconcat [[1], [2], [3]]
Semigroup.concat(
  ArrayT([1, 2]),
  ArrayT([3]),
);

Monoid.concat(
  Monoid.empty(ArrayT),
  ArrayT(["done"]),
);

Foldable.fold(
  ArrayT([
    ArrayT([1]),
    ArrayT([2]),
    ArrayT([3]),
  ]),
  Monoid.empty(ArrayT),
  Monoid.concat,
);

Semigroup provides associative combination. Monoid adds empty. The callable data dictionary is the runtime witness, so no placeholder value is needed. mempty(ArrayT) is the equivalent ./prelude spelling. A representative wrapped value remains accepted for compatibility. Maybe uses the deliberate First interpretation: Nothing is empty and the first Just wins.

Alternative

Nothing <|> Just 42
[1, 2] <|> [3, 4]
Alternative.empty(Maybe); // Nothing
Alternative.alt(Nothing<number>(), Just(42));

Alternative.alt(
  ArrayT([1, 2]),
  ArrayT([3, 4]),
);

Alternative is the common "empty plus choice" interface. Maybe chooses the first successful branch, arrays concatenate branches, and parser examples use the same idea for backtracking choices. The ./prelude equivalents are empty(Maybe) and alt(left, right).

Parser Combinators

Haskell parser libraries such as Megaparsec build larger grammars from small Functor, Applicative, Monad, and Alternative pieces:

parameters =
  between (symbol "(") (symbol ")") (identifier `sepBy` symbol ",")

declaration =
  choice [keyword "let", keyword "const"]

The programming-language parser case study follows that shape with ordinary TypeScript functions:

const parameters = between(
  symbol("("),
  sep_by(identifier, symbol(",")),
  symbol(")"),
);

const declaration = choice([
  keyword("let"),
  keyword("const"),
]);

The parser is still just another data type with typeclass instances; the combinators are convenience functions for the grammar domain.

Reader

endpoint :: Reader Config String
endpoint = do
  config <- ask
  pure (host config <> ":" <> show (port config))
const endpoint = Do(function* () {
  const config = yield* ask<Config>();

  return config.host + ":" + config.port.toString();
});

endpoint.run({ host: "localhost", port: 8080 });

For direct construction or pure, fix the environment once with Reader.with_environment<Config>():

const ConfigReader = Reader.with_environment<Config>();
const label = ConfigReader((config) => config.host)
  .bind((host) => ConfigReader.pure(host + ":ready"));

label.run({ host: "localhost", port: 8080 });

This is a typed view of the anonymous Reader dictionary. Use Reader cells when several environments need independent handlers.

Effects use run_reader when Reader is one capability inside a larger program:

const without_reader = run_reader(program, config);

Reader cells

ask addresses one anonymous environment, so a program asking for a Config and a Database needs one value satisfying both. When the two are genuinely separate, declare a cell for each and answer them independently:

const config = reader<"config", Config>("config");
const database = reader<"database", Database>("database");

const program = Program.scope<Uses<typeof config> | Uses<typeof database>>()(
  function* () {
    const url = yield* config.asks((value) => value.url);
    const pool = yield* database.asks((value) => value.pool);

    return url + "/" + pool;
  },
);

run(
  run_reader(
    database,
    run_reader(config, program, { url: "https://example.test" }),
    { pool: "main" },
  ),
);
// "https://example.test/main"

Cells follow the same rules as State cells: the key names the cell, widened or union keys are rejected, and repeated keys within one family address the same cell. A unique const symbol gives a private cell.

State

counter :: State Int Int
counter = do
  before <- get
  modify (+2)
  pure before
const counter = Do(function* () {
  const before = yield* get<number>();

  yield* modify((value: number) => value + 2);

  return before;
});

counter.run(40); // [40, 42]

State.with_state<state>() fixes the state type for direct construction and pure:

const Counter = State.with_state<number>();
const increment = Counter((state) => [state, state + 1] as const)
  .bind((previous) => Counter.pure(previous));

increment.run(40); // [40, 41]

The view shares the anonymous State handler; keyed cells give independent slots.

State cells

get and put address one anonymous cell, so a program that reads a number and a string is rejected: a single run_state handles every State lift, and one value cannot be both. When a program needs several independent slots, declare a cell for each. A cell has its own handler and its own initial value:

const total = state<"total", number>("total");
const label = state<"label", string>("label");

const program = Program.scope<Uses<typeof total> | Uses<typeof label>>()(
  function* () {
    const before = yield* total.get();

    yield* total.modify((value) => value * 2);
    yield* label.put("counted");

    return before;
  },
);

run(run_state(label, run_state(total, program, 20), ""));
// [[20, 40], "counted"]

Each handler removes only its own cell's lifts and passes the others through, so the two compose in either order. The results nest, one pair per handler.

The key names the cell so that two cells holding the same state type stay distinct — without it, every number cell would be one type. A key that is not a literal carries no identity, so widened string, number, and symbol keys are rejected at the declaration.

The key is also a runtime argument. Reusing a key within the same family addresses the same cell. Declare a const key = Symbol("counter") and pass it with state<typeof key, number>(key) when a module needs a private cell. Use one value type consistently for each shared key.

Cells are invisible to get/put and vice versa, so both styles can coexist in one program. Terminal keyed Reader, State, and Writer handlers can be lowered to their terminal runners by the source transformer.

Writer

program :: Writer [String] Int
program = do
  tell ["start"]
  pure 42
const program = Do(function* () {
  yield* tell(array.ArrayT(["start"]));

  return 42;
});

const [value, logs] = program.value();

value; // 42
array.to_array(logs); // ["start"]

Writer is parameterized by the monoidal output. Arrays are just one concrete choice through ArrayT; the same Writer machinery can accumulate any output with a Monoid implementation.

For direct Writer programs, Writer.with(emptyOutput) creates a configured dictionary that captures the output monoid and its identity once:

const LogWriter = Writer.with(ArrayT<string>([]));

const direct = Do(LogWriter, function* () {
  const value = yield* LogWriter([1, ArrayT(["start"])]);
  return value + 41;
});

LogWriter.pure(42); // Writer(42, [])

That configured dictionary gives pure and yield-free Do blocks a real empty log without requiring a dummy Writer value. The unconfigured Writer export remains useful for writer, tell, and effect handlers where an output value already supplies its Monoid.

Writer cells

tell accumulates into one anonymous log, so a program writing strings and numbers has no single accumulator to drain into. Declare a cell per output and drain each separately:

const audit = writer_cell("audit", ArrayT<string>([]));
const metrics = writer_cell("metrics", ArrayT<number>([]));

const program = Program.scope<Uses<typeof audit> | Uses<typeof metrics>>()(
  function* () {
    yield* audit.tell(ArrayT(["started"]));
    yield* metrics.tell(ArrayT([1]));

    return "done";
  },
);

const [[value, audit_log], metric_log] = run(
  run_writer(
    metrics,
    run_writer(audit, program, ArrayT<string>([])),
    ArrayT<number>([]),
  ),
);

Both Writer.with and Writer cells capture an empty output value, so their dictionary pure works without a wrapped receiver. Writer.with shares the base dictionary's runtime kind. Each distinct cell key has its own kind for a separate handler. Cells follow the same key rules as State cells. writer_cell(key, emptyOutput) infers the key, monoid, and log item from its arguments. The explicit writer_cell<key, output, log>(key, emptyOutput) spelling remains available.

Effects Instead of Transformers

Haskell often reaches for transformer stacks such as ReaderT Config (StateT Count (WriterT [String] IO)) item. This repo keeps the plain Reader, State, Writer, and Task data types separate, and composes mixed programs with Effect/Program instead:

type App =
  | Uses<AsReader<Config>>
  | Uses<AsState<number>>
  | Uses<AsWriter<AsArray, string>>
  | Uses<AsTask>;

const App = Program.scope<App>();

const program = App(function* () {
  const config = yield* ask<Config>();
  const before = yield* get<number>();

  yield* modify((value: number) => value + config.increment);
  yield* tell(ArrayT([config.label + ":" + before.toString()]));

  return yield* get<number>();
});

await Effect.interpret(program)
  .handle((effect) => run_reader(effect, { label: "step", increment: 2 }))
  .handle((effect) => run_state(effect, 40))
  .handle((effect) => run_writer(effect, ArrayT<string>([])))
  .run(run_task);

Each handler removes one capability from the effect type and returns a smaller effect. The final .run(run_task) call is the terminal runner that executes the remaining Task effect. That gives a transformer-like composition story without defining ReaderT, StateT, WriterT, and every concrete stack combination.

When a synchronous Reader, State, or Writer lift is the only remaining capability, run_reader_terminal, run_state_terminal, and run_writer_terminal return the final value without allocating a residual Effect. They also accept one matching Reader, State, or Writer data value directly. The source transformer selects these runners automatically for exact terminal shapes such as run(run_state(program, initial)) and fuses immediate straight-line Program bodies step by step. Composable run_reader/run_state/run_writer calls keep their existing behavior, while unsupported terminal shapes fall back to the general Effect path.

Custom operations

An operation's output is a phantom type: the tagged tuple has no runtime field from which TypeScript could infer what an interpreter will eventually return. Declare that output once with Effect.operation, then Effect.send preserves it without a type assertion:

const Clock = Effect.operation<string>()(["clock.now"]);

type Clock = typeof Clock;

function now() {
  return Effect.send(Clock);
}

Interpret a custom operation with Effect.handle_operation. The type guard selects the operation, the handler must produce its phantom output, and the result removes that operation from the requirement union while leaving every unmatched operation suspended:

import {
  Effect,
  has_tag,
  type WithoutOperation,
} from "jsr:@mewhhaha/typeclasses/effects";

function is_clock(operation: unknown): operation is Clock {
  return has_tag(operation, "clock.now");
}

function run_clock<requirements, item>(
  effect: Effect<requirements, item>,
  read_now: () => string,
): Effect<WithoutOperation<requirements, Clock>, item> {
  return Effect.handle_operation(
    effect,
    is_clock,
    () => Effect.pure(read_now()),
  );
}

The handler may return another effect instead of Effect.pure, which translates the custom operation into another capability. For promise-backed runtimes, handle_operation_task performs that translation into Task; its handler is deferred until run_task and receives the execution AbortSignal. Both forms resume long operation sequences without recursively growing the JavaScript stack.

When an operation carries a payload constructed at each call, supplying the operation type explicitly is also cast-free:

function read_todo(id: string): Effect<ReadTodo, DatabaseResult<Todo>> {
  return Effect.send<ReadTodo>(["database.read_todo", { id }]);
}

satisfies ReadTodo checks structural compatibility but does not attach the optional phantom output property for later inference.

Typed Failure

Haskell reaches for ExceptT when a program can stop early with a typed error:

program :: ExceptT Missing IO Int
program = do
  value <- lookupPort
  when (value < 0) (throwError (Missing "port"))
  pure value

Fails is that capability without the transformer stack. It joins the requirement union like Reader or State, and run_except removes it, collapsing the program's item into an Either:

type Missing = readonly ["missing", string];
type App = Uses<AsReader<Config>> | Uses<AsTask> | Fails<Missing>;

const App = Program.scope<App>();

const program = App(function* () {
  const config = yield* ask<Config>();

  if (config.port < 0) {
    yield* fail<Missing>(["missing", "port"]);
  }

  return config.port;
});

const handled = run_except(program);
const result = await run_task(run_reader(handled, config));

run_except catches every failure it meets, so it removes the whole failure capability at once and the left branch is the union of every error the program can raise. Both are read off the requirements rather than named, so they cannot disagree with what the handler actually catches.

fail never resumes, so the statements after it do not run and yield* fail(...) type-checks in any position. Task reports rejection as an untyped promise failure, so attempt awaits a promise with its rejection routed into the typed channel:

const fetched = App(function* () {
  return yield* attempt(
    () => fetch_port(),
    (cause): Missing => ["missing", String(cause)],
  );
});

from_either lifts an existing Either into the same channel, and recover handles a failure by supplying a replacement program:

const recovered = recover(program, (error) => Effect.pure(error[1].length));

The replacement's own requirements join the result, so a replacement that can fail keeps a Fails for the next handler to remove.

A failure raised inside Effect.ensuring is caught, the finalizer runs, and it observes { status: "failed" } carrying the error. A try/finally in the program body is not equivalent: a failure abandons the generator rather than resuming it, so its finally block never runs. Use Effect.ensuring for cleanup that must survive a failure.

Cleanup outcomes belong to each execution, including repeated and concurrent runs of one handled effect. run_task initializes that scope automatically. A custom terminal runner for Ensuring must resolve its optional prepare factory before entering the scope, repeating while another prepare remains. Forwarding handlers should retain the entire operation payload.

IO and Task

program :: IO Int
program = do
  text <- readFile "port.txt"
  pure (read text)
const program = Do(function* () {
  const text = yield* from_fn(() => Deno.readTextFile("port.txt"));

  return Number.parseInt(text, 10);
});

await program.run();

Task is deferred async work. It is intentionally a signal-aware thunk, (signal?: AbortSignal) => Promise<item>, so construction does not start the operation. Its MonadError instance turns throw_error into a deferred rejection and catch_error into deferred promise recovery. from_promise(promise) can adopt an existing promise, but that promise is already running; use from_fn when the operation itself must wait for .run().

Task items cannot themselves be PromiseLike. Keep .map(...) and applicative callbacks synchronous, and use .bind(...) or from_fn(...) for dependent asynchronous work. Pass an AbortSignal to .run(signal) or to run_task(effect, { signal }). The signal propagates through mapped, bound, and applicative tasks; a ParallelTask failure aborts its siblings. from_fn passes the execution signal to its producer so cooperative work can stop. Aborting from_promise stops waiting but cannot undo work already started by the original promise. Adopted promise rejections remain observed even when execution starts with an already-aborted signal. Use run_task_exit when success, failure, and cancellation must be handled as values.

run_task and run_task_exit accept a direct Task or ParallelTask as well as a handled effect. Use the same runner when an application has both forms:

await run_task(from_fn(async () => 42));
await run_task_exit(from_fn(async () => 42)); // { status: "succeeded", value: 42 }

Async and Concurrency

Haskell separates applicative independence from monadic dependency:

UserAndScore <$> fetchUser id <*> fetchScore id

fetchUser id >>= fetchProfile

Task runs both applicative and monadic composition sequentially. Select ParallelTask explicitly when independent operations should start together:

import { from_fn, parallel, sequential } from "@mewhhaha/typeclasses/task";

const independent = Applicative.lift(
  (user, score) => ({ user, score }),
  parallel(from_fn(() => fetch_user(id))),
  parallel(from_fn(() => fetch_score(id))),
);

const dependent = Do(function* () {
  const user = yield* from_fn(() => fetch_user(id));

  return yield* from_fn(() => fetch_profile(user.id));
});

await independent.run();
await dependent.run();

const combined_task = sequential(independent); // supports bind and error recovery

Applicative style expresses independence; concurrency is a property of the selected dictionary. ParallelTask provides Functor and Applicative, while Task also provides Monad and MonadError. Traversable.traverse(values, ParallelTask, (value) => parallel(load(value))) uses the same explicit choice for a collection. run_task and run_task_exit can execute lifts from either dictionary.

Task and ParallelTask run asynchronous work on one JavaScript isolate. CPU-bound work can cross isolate boundaries with Web Workers:

const summaries = await worker_map<LogShard, LogShardSummary>(
  worker_url,
  shards,
  { workers: 4 },
).run();

worker_map creates a one-shot pool. with_worker_pool and worker_pool_map reuse a bounded pool across multiple batches; batches submitted to one pool run in order, while the jobs inside each batch are distributed across its workers. Worker inputs and outputs must be structured-clone-safe values rather than wrapped dictionaries, functions, or TVars. Pool and batch options accept an AbortSignal. A reusable pool closes after a worker failure or an aborted active batch, so recovery creates a new pool instead of reusing workers with unknown state.

STM

atomically $ do
  before <- readTVar counter
  writeTVar counter (before + 1)
  pure before
const counter = new_tvar(0);

const increment = Do(function* () {
  const before = yield* read_tvar(counter);

  yield* write_tvar(counter, before + 1);

  return before;
});

atomically(increment);

Stm keeps writes in a journal until atomically commits them. retry and or_else provide the familiar transactional choice shape, so failed branches can roll back their writes before another transaction is attempted.

This Stm implementation is synchronous and isolate-local. Transactions cannot span await, retry selects an immediate or_else branch rather than waiting for a TVar change, and TVars are not shared with Web Workers. It is useful for atomic admission and fallback decisions in the coordinator isolate; the worker pool remains responsible for parallel execution.

Resource Handling

Haskell's bracket and finally make cleanup explicit around effectful code:

bracket acquire release use

JavaScript already has the same control-flow shape with try/finally, and not every platform resource implements [Symbol.dispose]:

async function* from_stream<item>(stream: ReadableStream<item>) {
  const reader = stream.getReader();

  try {
    while (true) {
      const next = await reader.read();

      if (next.done) {
        return;
      }

      yield next.value;
    }
  } finally {
    reader.releaseLock();
  }
}

The important Haskell lesson is the bracket shape: acquire, use, and release stay together. The repo keeps that shape direct instead of hiding it behind a typeclass until a data type needs a reusable resource abstraction.

Validation

Haskell validation examples usually use an applicative that accumulates errors instead of stopping at the first one:

Profile <$> validateName input <*> validateEmail input
Applicative.lift(
  (name, email) => ({ name, email }),
  validate_name(input),
  validate_email(input),
);
const checked_profile = Applicative.lift(
  (name, age) => ({ name, age }),
  Valid("Ada"),
  InvalidMessages<number>("age is required"),
);

const checked = checked_profile.value();

if (Invalid.is(checked)) {
  checked[1]; // ["age is required"]
}

Validation implements Applicative, Functor, Foldable, Traversable, and Ord, but not Monad: a lawful monad would make later validations depend on earlier values and lose independent error accumulation.

Like Writer, Validation separates the accumulation rule from the default error shape. InvalidMessages("message") is a convenience for readonly string[] errors, while Invalid(error, semigroup) can accumulate any error payload with an explicit semigroup. Valid and Invalid are typed constructor exports over the Validation dictionary, so custom error payloads can be represented without caller-side casts.

For repeated custom errors, configure the dictionary once and use its one- argument constructors:

const Problems = Validation.with_semigroup<readonly string[]>({
  concat: (left, right) => [...left, ...right],
});

const missing_name = Problems.Invalid(["name is required"]);
const renamed = map_error(
  missing_name,
  (messages) => new Set(messages),
  { concat: (left, right) => new Set([...left, ...right]) },
);

Error mapping requires the target semigroup explicitly. This is why Validation does not offer Bifunctor: an arbitrary function can change the error type, but it cannot derive a lawful accumulation rule for that new type.

JavaScript Shapes

The useful question for a JavaScript shape is which laws it can support without surprising runtime behavior.

JavaScript shape Wrapper in this repo Natural typeclasses
readonly item[] ArrayT Functor, Applicative, Monad, Foldable, Traversable, Semigroup, Monoid, Alternative
recursive list List Same list-like typeclasses, useful for generator-heavy algorithms
ReadonlyMap<string, item> MapT Functor, Foldable, Traversable, Semigroup, Monoid
Readonly<Record<string, item>> RecordT Same value-focused typeclasses as MapT, plus lexicographic Ord
Set<item> SetT Functor, Foldable, Semigroup, Monoid; mapping keeps JavaScript set semantics and can collapse duplicates
PromiseLike<item> Task via from_promise Adopts work that is already running; use from_fn to defer starting it
() => Promise<item> Task via from_fn Functor, sequential Applicative, Monad, MonadError; ParallelTask opts into concurrency
Iterable<item> / generator IterableT replayable lazy Functor, Applicative, Monad, Foldable, Traversable, Semigroup, Monoid, Alternative
AsyncIterable<item> AsyncIterableT replayable async Functor, Applicative, Monad, Semigroup, Monoid, Alternative; collect with to_array
ReadableStream<item> ReadableStreamT opaque stream wrapper plus to_async_iterable; native streams are stateful and can be locked/consumed
typed arrays TypedArrayT Show, Eq, Foldable; no general Functor because output must stay compatible with the typed-array constructor
ArrayBuffer / DataView ArrayBufferT / DataViewT byte-level Show, Eq, Foldable, Semigroup, Monoid
URLSearchParams / FormData URLSearchParamsT / FormDataT entry-level Show, Eq, Foldable, Semigroup, Monoid; usually decode into Either or Validation first
WeakMap / WeakSet WeakMapT / WeakSetT opaque Show and identity Eq; no fold because JavaScript intentionally makes them non-iterable
Date, RegExp, Error DateT, RegExpT, ErrorT Show and Eq utility wrappers, not Functor/Monad containers

SetT keeps JavaScript Set behavior. Equality is identity for objects and SameValueZero for primitives, so mapping can collapse values:

new Set([1, 2, 3].map(() => 0)); // Set { 0 }

That can still be useful, but it is set behavior rather than list behavior.

For IterableT and AsyncIterableT, the main design choice is replayability. Many iterators are one-shot mutable cursors. The preferred constructors store a factory, () => Iterable<item> or () => AsyncIterable<item>. The plain from_iterable helper materializes values to make a replayable source. await async_iterable.from_async_iterable(source) likewise snapshots a finite async source before returning its wrapper. Use from_factory for lazy sources and supply a fresh iterator on every call. Stream adapters remain stateful and are intended for one consumption. Calling a continuation more than once must not accidentally reuse a consumed iterator.

Byte wrappers fold numbers; form and URL parameter wrappers fold their entry tuples. These fixed element types survive generic operations such as empty and fold. TypedArrayT(new BigInt64Array(...)) folds bigint, while numeric typed arrays fold number.

Chained IterableT maps compose lazily. Each .map adds a generator layer, but it does not allocate an intermediate collection. Work happens when a consumer iterates, folds, or materializes the final value:

const values = iterable.from_factory(function* () {
  yield* [1, 2, 3];
});

const pipeline = values
  .map((value) => value + 1)
  .map((value) => value * 10)
  .map((value) => "value:" + value.toString());

iterable.to_array(pipeline); // ["value:20", "value:30", "value:40"]

For ordinary arrays, iterable.from_array(items) supplies a live view without copying. Later source mutations are visible on subsequent reads. Use from_iterable(items) when you want a snapshot. Lazy filter supports type guards; take bounds consumption without reading ahead and closes upstream on early exit. Both are available as fluent methods and standalone helpers:

import { iterable } from "@mewhhaha/typeclasses";

const items = [1, 2, 3, 4, 5];
const pipeline = iterable.from_array(items)
  .filter((value) => value % 2 !== 0)
  .map((value) => value * 10)
  .take(2);

pipeline.fold(0, (sum, value) => sum + value); // 40, without an output array
iterable.to_array(pipeline); // [10, 30]

take requires a nonnegative safe integer; take(0) never opens the source. Collection traverse still materializes its input and result. Use lazy mapping, filtering, and folding for streaming work. Generator layers have execution overhead, so benchmark throughput separately from allocation. The source plugin can fuse supported lazy map chains as described under Build-Time Transform.

ArrayT.map keeps its immutable behavior. For explicit updates of ordinary JavaScript arrays, the array namespace provides two small utilities:

import { array } from "@mewhhaha/typeclasses";

const items = [1, 2, 3];
array.map_in_place(items, (value) => value * 2); // items is now [2, 4, 6]

const output: string[] = [];
array.map_into(items, output, (value) => value.toString()); // ["2", "4", "6"]
// Reuse output on subsequent calls instead of allocating a new output array.

Both functions return void, visit indices within the initial source length, and skip holes. map_in_place keeps the element type and array identity, so aliases see the writes. map_into resizes the destination and deletes stale entries at source holes; passing the same array as source and destination performs in-place mapping. A thrown callback leaves completed writes in place. The source plugin does not turn ordinary array maps into mutations.

For nested application state, an external library such as Immer can offer convenient draft updates while preserving previous state and sharing unchanged objects. Changed array containers still require copying. This is an application-level suggestion; the core library does not depend on a proxy update library.

ReadableStream has similar constraints plus cancellation and backpressure. The pragmatic shape is usually:

async function* from_stream<item>(stream: ReadableStream<item>) {
  const reader = stream.getReader();

  try {
    while (true) {
      const next = await reader.read();

      if (next.done) {
        return;
      }

      yield next.value;
    }
  } finally {
    reader.releaseLock();
  }
}

ReadableStreamT exposes this as to_async_iterable, which returns an AsyncIterableT. Running tasks in workers is a runner concern layered under Task, not a separate data type, so it is intentionally not modeled here.

Built-In Shapes

src/array.ts, src/map.ts, src/record.ts, and the other built-in-shaped modules wrap familiar JavaScript data shapes without replacing them:

  • ArrayT<item> wraps readonly item[] and implements list-like Functor, Applicative, Monad, Foldable, Traversable, Semigroup, Monoid, and Alternative.
  • MapT<item> wraps ReadonlyMap<string, item> and implements value-focused Functor, Foldable, Traversable, Semigroup, and Monoid.
  • RecordT<item> wraps Readonly<Record<string, item>> with the same value-focused typeclasses as MapT.
  • SetT<item> wraps ReadonlySet<item> with JavaScript set semantics.
  • IterableT<item> and AsyncIterableT<item> use replayable factories.
  • ArrayBufferT, DataViewT, and TypedArrayT expose byte/numeric folding without pretending binary buffers are general-purpose functors.
  • URLSearchParamsT and FormDataT fold over key/value entries.
  • WeakMapT, WeakSetT, DateT, RegExpT, and ErrorT are utility wrappers for formatting/equality rather than collection programming.

Maps and records use right-biased concat, where values from the right side replace matching keys from the left side. They intentionally do not implement Applicative or Monad, because there is no obvious lawful pure for an arbitrary key space.

The root module exposes four collection namespaces. Other shapes use their focused entrypoints:

import { array, iterable, map, set } from "@mewhhaha/typeclasses";
import * as async_iterable from "@mewhhaha/typeclasses/async_iterable";
import * as record from "@mewhhaha/typeclasses/record";
import * as typed_array from "@mewhhaha/typeclasses/typed_array";
import * as url_search_params from "@mewhhaha/typeclasses/url_search_params";

Each data type has an open dictionary interface such as AsMaybe or AsList. Entries are added one typeclass at a time next to the implementation. TypeScript checks the required methods in Show.instance(Maybe)({ ... }). Installation records the implementation under its symbol and copies direct fluent aliases onto the dictionary, rejecting conflicting alias names.

Property Checking

The optional, dependency-free /quickcheck entrypoint generates reproducible inputs, shrinks failures, and checks common typeclass laws. Use example-based tests for named domain cases and properties for invariants that should hold over many values.

Gen is a regular Monad, so fluent methods and Do compose dependent generators:

import { Do } from "@mewhhaha/typeclasses";
import {
  element,
  Gen,
  integer,
  sample,
} from "@mewhhaha/typeclasses/quickcheck";

const request = Do(Gen, function* () {
  const method = yield* element(["GET", "POST"] as const);
  const id = yield* integer({ min: 1, max: 1_000 });

  return { method, path: "/read/" + id.toString() };
});

sample(request, { seed: 42, count: 5 });

A Gen<item> only creates values. An Arbitrary<item> adds a shrinker, which lets check reduce a failing input toward a smaller counterexample:

import { check, integer_arbitrary } from "@mewhhaha/typeclasses/quickcheck";

check({
  arbitrary: integer_arbitrary(),
  seed: 42,
  iterations: 500,
  property: (value) => value + 0 === value,
});

A property passes when it returns true or void. Returning false or throwing produces PropertyFailure, including the generated case's seed, size, original value, smallest counterexample found, and shrink count. Replay that exact case with iterations: 1, the reported seed, and start_size: failure.size.

The module includes arbitraries for integers, booleans, strings, arrays, Maybe, Either, and List, plus pair and triple combinators. Build a domain arbitrary from those pieces with map_arbitrary; its reverse conversion keeps shrinking available. Do not put randomness inside production code.

For a custom instance, combine its domain behavior tests with the reusable law collections for Functor, Applicative, Monad, MonadError, Semigroup, Monoid, Alternative, Foldable, Traversable, Eq, Ord, Bifunctor, Contravariant, Profunctor, Category, Arrow, and Comonad:

import {
  arbitrary,
  check_laws,
  element,
  functor_laws,
  integer_arbitrary,
  maybe_arbitrary,
} from "@mewhhaha/typeclasses/quickcheck";

const values = maybe_arbitrary(integer_arbitrary());
const functions = arbitrary(element([
  (value: number) => value,
  (value: number) => value + 1,
  (value: number) => -value,
]));

check_laws(
  functor_laws({
    values,
    functions,
    equals: (left, right) => left.eq(right),
  }),
  { seed: 42 },
);

Use check_async for promises. Use check_effect for Effect programs and provide the interpreter explicitly; the property describes the program while the test controls Reader services, State, Writer output, clocks, or custom operations through handlers:

await check_effect({
  arbitrary: integer_arbitrary({ min: 0 }),
  property: (subtotal_cents) => price_never_decreases(subtotal_cents),
  run: (effect) =>
    run(run_reader(effect, {
      basis_points: 2_300,
    })),
});

This keeps generation pure and deterministic. Randomness is test input, not an implicit effect capability, and effect properties are checked under the same handlers used by ordinary tests.

Examples

Focused repository examples live in examples/ and progress from individual operations to application-shaped workflows:

examples/main.ts is only a runner for those focused files.

learn_you_a_typeclasses_for_greater_good/ is a longer Haskell-inspired tutorial made of executable lessons. It is included in the published package. Run it from the repository with deno task learn.

Larger repository-only application-shaped demos live in case_studies/:

  • case_studies/http_router/ builds a small typed HTTP router on URLPattern. router.ts contains the UrlPatternList data type and route composition, while handlers.ts and mod.ts define the concrete HTTP app. Routes carry method checks, typed path params, typed query params, and compose as a first-match Alternative route list. Handlers are Programs with Reader for route input and Writer<AsyncIterableT<string>> for streamed response bodies, so the same router can return HTML pages or JSON responses.
  • case_studies/cloudflare_crud_worker/ models a Cloudflare Worker CRUD API for /todos. The request program uses Reader for request context, Task for JSON body reads and async storage, a custom Database effect for list, create, read, update, and delete operations, custom Clock effect for timestamps, and a custom Trace effect for request and domain events. A trace-scope interpreter can also observe selected effects before their concrete runner, so database operations automatically produce crud.database.*.start and crud.database.*.finish trace lines without adding trace calls to the route handlers. The same program can run against an in-memory dry-run database with trace lines collected through Writer, or against a D1-style runtime with trace events sent through a console/task sink.
  • case_studies/io_application/ models a small CLI with echo, cat, and write commands. It uses a custom FileSystem effect for ReadFile/WriteFile, Reader for argv, Writer for stdout lines, and Task only at the interpreter boundary. The same program runs against an IO interpreter or a dry-run interpreter that records planned writes without mutating the backing store.
  • case_studies/agent_harness/ models an agent loop as another IO program. A custom LanguageModel effect produces tool requests or a final answer; the harness executes filesystem tools, appends tool results to the transcript, writes stdout with Writer, and repeats until the model returns final.
  • case_studies/parallel_analyzer/ compares sequential analysis, one-shot workers, and a reused worker pool. Its Parallel effect keeps worker execution behind an interpreter while preserving input order in reports.

Benchmarks

The 131–210 performance ledger records measurements, rejected experiments and workload limits. New explicit paths include:

Need API and constraint
Append many Writer log chunks ChunkLog, chunk_log, flatten_chunk_log from ./chunk_log; flatten once at the boundary.
Reuse handler setup prepare_operations and prepare_cells from ./handlers; preparation snapshots the supplied table.
Run an explicit effect graph compile_effect_plan from ./effects; keep state immutable for repeated resumes.
Combine finite lists concat_many from ./list; copies each element once.
Compile a known lazy terminal iterable.compile_fold; specializes map/filter/take, with a general fallback.
Consume synchronous values asynchronously map_sync and from_sync_factory from ./async_iterable; these trust synchronous intermediate values.
Consume asynchronous chunks from_chunks from ./async_iterable; direct materialization avoids scalar iteration.
Stream worker results with bounded input pool.stream(source, { window_size }); ordered windows provide backpressure and local transfer indexes.
Bound worker admission max_pending_items, or max_pending_bytes with input_bytes; byte limits use the caller's estimate.
Cancel workers cooperatively serve_worker(handler, { cooperative: true }) and pool abort_grace_ms; unresponsive workers are replaced.
Update immutable transactional trees update_immutable_path from ./stm; existing paths only, with untouched branches shared.

array.flat_map_streaming keeps one callback chunk active at a time but can be slower than ordinary bind. new_tvar(value, { shape }) accepts a checked SnapshotShape; flat records benefit in the measured workload, while arrays can regress. Choose these paths using representative measurements.

deno task profile:allocation WORKLOAD.ts OUTPUT.json samples Node allocation traffic, GC pauses and p99 latency. profile_budget_failures accepts explicit relative budgets for matching workload/runtime/sample settings; sampled bytes include the harness and are not exact object counts.

Benchmark timing runs separately from the correctness gate. Fixture equivalence and scaling preflights also run through deno task test:benchmarks. Run every benchmark or select a focused comparison:

deno task bench
deno bench --allow-env --allow-read --allow-write=/tmp bench/algorithm_contexts.bench.ts
deno bench bench/iterable_pipeline.bench.ts
deno bench --allow-ffi --allow-env --allow-read --allow-write=/tmp bench/do_vs_program.bench.ts
deno task bench:case-studies
deno task bench:scaling

bench/algorithm_contexts.bench.ts runs the same Functor scoring, Applicative product, and Monad-dependent product algorithms through native arrays and generators, ArrayT, List, IterableT, Maybe, Either, Validation, and the functor-only MapT, RecordT, and SetT. IterableT is intentionally replayable (() => Iterable<item>) so its list-like Applicative and Monad remain lawful. Raw JavaScript iterators are one-shot, so the native generator baselines are forced to arrays before their result is recorded.

bench/iterable_pipeline.bench.ts compares one lazy IterableT pipeline with materialized Array.map steps, native generator maps, and a manual fused loop. bench/do_vs_program.bench.ts compares runtime generator interpretation with actual generated modules from the source transformer, including direct Do chains, optimized Effect spines, and statically visible terminal handlers. Preflights compare outputs, failure behavior, and observable events before timing. Case-study benchmarks check original and transformed reports and expose preserved-source diagnostics.

bench/large_chains_comparison.bench.ts compares repeated Maybe and Either chains with other libraries. Its runtime and transformed Do variants share the same source fixture; preflights check success, short-circuit failures, and observable events.

bench/scaling.bench.ts exercises Array, Map, Record, and Iterable traversal, impure Effect bind chains, and pure Program yields at 1,000 through 16,000 elements or steps. Traversal stores immutable branches and materializes once; Effect continuations share their remaining frames. These workloads reveal allocation growth and stack limits that short happy-path chains miss. Use increasing-size results to assess scaling; absolute timings depend on the host.

bench/value_construction.bench.ts compares the current prototype-chain wrapper against constructor-cache variants and cheaper construction shapes. Each benchmark iteration performs 10,000 inner-loop constructions or read cycles:

  • raw maybe payload construction
  • current Just(...), Maybe(raw), and as_data(dictionary, raw) construction
  • cached as_data_cached(dictionary)(raw) construction
  • WeakMap, hidden-symbol, and lazy self-replacing constructor-cache variants
  • tuple [dictionary, raw] construction
  • record { dictionary, raw } construction
  • prototype-backed symbol object construction
  • value() or direct payload reads for the current, tuple, and prototype shapes

bench/performance_breakdown.bench.ts isolates hot-path costs for Maybe: construction, value() reads, fluent map/bind, generic typeclass helpers, runtime Do, and monomorphic versus mixed .bind call sites. For hot loops, prefer call sites that mostly see one data type shape; a single helper that alternates Maybe, Either, List, and other wrappers gives the JIT less stable receiver information.

bench/library_comparison.bench.ts compares this repository's Maybe and Either wrappers with similar data types from fp-ts, effect, purify-ts, and true-myth:

  • Maybe/Maybe and Either/Either construction.
  • Happy-path map plus bind/chain/flatMap composition.
  • Failure-path nothing/left/left composition.

These are microbenchmarks, not a full library ranking. The libraries expose different runtime shapes: this repo boxes values with a data dictionary, fp-ts uses standalone combinators over plain tagged objects, effect uses optimized module functions, purify-ts uses methods on ADT instances, and true-myth uses standalone functions over ADT instances.

Run it with:

deno task bench

About

Exploring typeclasses in TypeScript, uses a bunch of hacks.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages