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.
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>()); // 0One 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.
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.
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.
Install from JSR with the command for your runtime:
deno add jsr:@mewhhaha/typeclasses
npx jsr add @mewhhaha/typeclasses
bunx jsr add @mewhhaha/typeclassesNode 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.
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 |
The library provides these typeclass definitions:
FunctorformapApplicativeforpureandapMonadforbindandDoFoldableforfoldTraversablefor flipping structures through an applicativeOrdfor ordered comparisonsSemigroupandMonoidfor appendable/empty structuresAlternativefor empty/fallback list-like contextsBifunctor,Contravariant, andProfunctorfor multi-variance mappingCategoryandArrowfor composable function-like contextsComonadfor extracting and extending contextual valuesMonadErrorfor monads with recoverable failuresParsefor parser-like values that can consume string inputShowandEqas 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:
- Focused examples move from basic values to validation, async workflows, worker pools, STM coordination, and effect programs.
- The executable tutorial builds the ideas across 15 lessons.
- Fluent API and Function Prelude cover the two public calling styles.
- Haskell Comparisons maps familiar abstractions to this library's TypeScript representation.
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/typeclassesfor the common fluent core;/preludefor function-first helpers./typeclassand/typeclassesfor dictionary machinery and definitions./maybe,/either,/validation,/identity,/fn,/tuple,/array,/predicate,/list, and/taggedfor 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_setfor JavaScript shape wrappers and their conversions./task,/effects,/except,/reader,/state,/writer,/stm, and/parallelfor effectful programs./quickcheckfor deterministic generators, shrinking, property checks, and reusable typeclass laws./loopfor stack-safe tail loops./transformand/transform/pluginfor 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.
| 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.
In 0.15.0:
-
Wrapped public types consistently use
Foo<T>; raw values useRawFoo<T>. -
Declare
const identity = Symbol("Name")and pass it todata<AsName>(identity). The type and runtime dictionary share that identity. -
Nonempty
collect({ id: Just(1), name: Just("Ada") })infers its dictionary. Empty shapes still usecollect(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 usesMaybe<T>,Either<E, T>, andValidation<E, T>for wrapped values. UseRawMaybe,RawEither, andRawValidationfor 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
liftfor every arity andconcatfor semigroup combination. Thelift_Aaliases andappendwere removed. Examples are no longer exported from the root library entry point. -
Both prelude traversal helpers use
(fn, dictionary, value);traverse_now matchestraverse. -
collect(Maybe, { id: Just(1), name: Just("Ada") })preserves record and tuple inference.get_or_elseandrecover_or_elseevaluate their fallback lazily.bind_widenexplicitly combines Either error types. -
Keep
Dofor generator notation and branching. A measuredDo_onceprototype stayed underbench/experiments: eight-yield Maybe improved 10.7%, while Either slowed 12.3%, missing the half-runtime target. Usetraverse_sequentialwhen later callbacks must not run after failure. -
run_taskexecutes already-completed pure steps in the same turn. Use an asynchronous Task when a scheduling boundary is required. -
Lazy iterables provide bounded
preview, asyncfilterandtake, and orderedmap_concurrent.traverse_taskbounds Task admission. Stream iteration cancels on early exit unlesspreventCancelis set. -
The 60-hill ledger records measurements, API changes, rejected experiments, and qualification limits.
-
In
0.13.0,iterable.from_arraysupplies a live array view, with lazyfilterandtakehelpers.array.map_in_placeandarray.map_intomake buffer updates explicit. The source plugin fuses supported direct lazy map chains;ArrayT.mapkeeps its immutable behavior. -
In
0.12.0,Either.Left<error, item>andValidation.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 withEither.with_left<error>().Left<item>(error)orValidation.with_semigroup(semigroup).Invalid<item>(error). -
Task.apandApplicative.lifton Tasks now run sequentially, in input order, matchingbind. Wrap independent inputs withparallel(task)to use the applicative-onlyParallelTaskdictionary. Usesequential(result)when the combined task needs to enter aDoblock or useMonadErrorrecovery. -
Writer cells now require an empty-log witness:
writer_cell<"audit", AsArray, string>("audit", ArrayT<string>([])). Their dictionaries supportpureand yield-freeDoblocks without a dummy Writer value. -
await async_iterable.from_async_iterable(source)snapshots a finite async source for replay. Usefrom_factoryto 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_datachecks typed dictionaries' raw shapes even through its object-dictionary overload. -
The transformer diagnoses captured mutable state across yields and arbitrary
for...ofsources. 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:
Taskis sequential andParallelTaskstarts independent work together. -
Task cancellation is now supplied at execution with
.run(signal)orrun_task(effect, { signal }), so it propagates through the whole composed computation.from_fnandfrom_promiseno longer accept constructor options. Userun_task_exitwhen 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, andsetalso use focused subpaths. -
The prelude's
thenis nowsequence_right. Exportingthenmakes the module namespace thenable, so dynamic imports try to call it instead of resolving to the module. -
Generic type parameters formerly named
outare now namedoutputso 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
Asan explicit nominal identity. Declare a module-localunique symboland pass its type as the second argument, as inAs<AsExample, typeof example_identity>. This prevents two dictionaries with identical raw shapes from being mixed accidentally. -
Validationno longer implementsBifunctor: changing the error type cannot lawfully preserve its error semigroup. Replace error-sidebimapcalls withmap_error(validation, fn, target_semigroup), then use.map(...)for the success side. UseValidation.with_semigroup(semigroup)when several custom errors must accumulate. -
The public API uses snake-case names consistently, including
with_left,with_monoid, andmap_error.
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 verifyverify 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 benchThe 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.
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.
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)); // trueIf 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 againtoString 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.
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.
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);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 sitesPass { 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.tsFiles 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.
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 });
});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.
fmap (+1) (Just 41)fmap((value) => value + 1, Just(41));
Functor.map(Just(41), (value) => value + 1);
Just(41).map((value) => value + 1);(+) <$> Just 20 <*> Just 22Applicative.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 ./preludeparse input >>= validate >>= savebind(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 valueDo(function* () {
const text = yield* parse(input);
const value = yield* validate(text);
return yield* save(value);
});safePort :: String -> Maybe Int
safePort text =
readMaybe text >>= \port ->
if port > 0 then Just port else Nothingfunction 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 >>= validatefunction decode(input: unknown) {
return parse(input)
.bind(validate);
}compare (Just 2) (Just 1)
maximum [20, 42]Ord.compare(Just(2), Just(1)); // "gt"
Ord.max(identity(20), identity(42)).value(); // 42Ord extends Eq and returns "lt", "eq", or "gt". Maybe, Either,
Validation, Identity, ArrayT, List, and RecordT implement the expected
tag or lexicographic ordering.
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.
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]; // 42Tuple<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.
contramap score positiveconst positive = predicate((value: number) => value > 0);
const positive_score = Contravariant.contramap(
positive,
(user: { readonly score: number }) => user.score,
);
positive_score.run({ score: 1 }); // truePredicate is contravariant because mapping happens before the value reaches
the predicate.
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.
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.
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.
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.
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.
[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.
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).
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.
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);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.
counter :: State Int Int
counter = do
before <- get
modify (+2)
pure beforeconst 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.
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.
program :: Writer [String] Int
program = do
tell ["start"]
pure 42const 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.
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.
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.
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.
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 valueFails 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.
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 }Haskell separates applicative independence from monadic dependency:
UserAndScore <$> fetchUser id <*> fetchScore id
fetchUser id >>= fetchProfileTask 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 recoveryApplicative 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.
atomically $ do
before <- readTVar counter
writeTVar counter (before + 1)
pure beforeconst 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.
Haskell's bracket and finally make cleanup explicit around effectful code:
bracket acquire release useJavaScript 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.
Haskell validation examples usually use an applicative that accumulates errors instead of stopping at the first one:
Profile <$> validateName input <*> validateEmail inputApplicative.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.
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.
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>wrapsreadonly item[]and implements list-likeFunctor,Applicative,Monad,Foldable,Traversable,Semigroup,Monoid, andAlternative.MapT<item>wrapsReadonlyMap<string, item>and implements value-focusedFunctor,Foldable,Traversable,Semigroup, andMonoid.RecordT<item>wrapsReadonly<Record<string, item>>with the same value-focused typeclasses asMapT.SetT<item>wrapsReadonlySet<item>with JavaScript set semantics.IterableT<item>andAsyncIterableT<item>use replayable factories.ArrayBufferT,DataViewT, andTypedArrayTexpose byte/numeric folding without pretending binary buffers are general-purpose functors.URLSearchParamsTandFormDataTfold over key/value entries.WeakMapT,WeakSetT,DateT,RegExpT, andErrorTare 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.
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.
Focused repository examples live in examples/ and progress from individual
operations to application-shaped workflows:
examples/basics.tscoversMaybe,Either, generic typeclass functions, applicative combination, and tagged-value switches.examples/validated_request.tsparses nativeFormDatainto a typed request while accumulating all independent field errors.examples/composable_functions.tscombines reusable predicates and adapts typed function pipelines to an order domain.examples/built_in_shapes.tscovers JavaScript-shaped wrappers such as arrays, maps, sets, iterables, streams, form data, and binary buffers.examples/monads.tsshowsDowithReader,State,Task,Stm, and fail-fast decoding withEither.examples/do_contexts.tscontrastsDosemantics acrossMaybeshort-circuiting and dependentListbranching.examples/matching.tsdemonstrates exhaustive standalone and fluent matching for custom unions,Maybe, andEither.examples/quickcheck.tscomposes generators withDoand checks an effectful property under a Reader test handler.examples/task_workflow.tsstarts independent request branches together, fans out account-dependent calls as soon as their input arrives, and recovers an optional-service failure.examples/worker_pool.tsreuses a bounded Web Worker pool across ordered batches of structured-clone-safe log analysis jobs.examples/stm_coordination.tsuses synchronous local transactions to admit requests into primary and overflow queues with rollback on full queues.examples/effects.tscomposesReader,State,Writer, andTaskwithProgram.examples/instrumented_effects.tswraps a custom stock operation with tracing in an effect-transforming handler, then interprets those trace operations throughWriter.examples/custom_typeclass.tsshows extending a data type with a local typeclass.
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 onURLPattern.router.tscontains theUrlPatternListdata type and route composition, whilehandlers.tsandmod.tsdefine the concrete HTTP app. Routes carry method checks, typed path params, typed query params, and compose as a first-matchAlternativeroute list. Handlers arePrograms withReaderfor route input andWriter<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 usesReaderfor request context,Taskfor JSON body reads and async storage, a customDatabaseeffect for list, create, read, update, and delete operations, customClockeffect for timestamps, and a customTraceeffect for request and domain events. A trace-scope interpreter can also observe selected effects before their concrete runner, so database operations automatically producecrud.database.*.startandcrud.database.*.finishtrace 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 throughWriter, or against a D1-style runtime with trace events sent through a console/task sink.case_studies/io_application/models a small CLI withecho,cat, andwritecommands. It uses a customFileSystemeffect forReadFile/WriteFile,Readerfor argv,Writerfor stdout lines, andTaskonly 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 customLanguageModeleffect produces tool requests or a final answer; the harness executes filesystem tools, appends tool results to the transcript, writes stdout withWriter, and repeats until the model returnsfinal.case_studies/parallel_analyzer/compares sequential analysis, one-shot workers, and a reused worker pool. ItsParalleleffect keeps worker execution behind an interpreter while preserving input order in reports.
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:scalingbench/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), andas_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/MaybeandEither/Eitherconstruction.- Happy-path
mapplusbind/chain/flatMapcomposition. - Failure-path
nothing/left/leftcomposition.
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