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 is the one exception: it uses the TypeScript
compiler, and never ships to your runtime.)
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,
} from "@mewhhaha/typeclasses";
function total<dictionary extends Foldable<dictionary>>(
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 — measured at 3–15× on this repository's benchmarks — so the
readable spelling is also the fast one. 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 |
parallel Applicative, sequential Monad, MonadError |
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 remains the convenient full library. Stable granular entrypoints let applications and build tools import only the domain they need:
@mewhhaha/typeclassesand/preludefor the full fluent and function-first APIs./typeclassand/typeclassesfor dictionary machinery and definitions./maybe,/either,/validation,/identity,/fn,/tuple,/array,/predicate,/list, and/taggedfor data values./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 wrappers for built-in JavaScript shapes are reached through a namespace at
the root, because their helpers would otherwise collide — every one of them
wants to export from_array, to_array, from_entries, and friends. 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 | Runtime-neutral core; not CI-smoked |
Task |
CI | CI | CI | Uses 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.
- 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. - Existing root and
/preludeimports continue to work except for the prelude'sthen, which is nowsequence_right. Exportingthenmakes the JavaScript module namespace thenable, so dynamic imports try to call it instead of resolving to the module. The granular domain entrypoints are additive and are preferred when an application needs only one part of the library. - 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.
Benchmarks stay separate because they are a measurement harness rather than a
correctness gate:
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, so two typeclasses can both have a method named show without
sharing a runtime property.
import {
$slot,
type As,
data,
type type_data,
type type_item,
union,
} from "@mewhhaha/typeclasses/typeclass";
import { Monad, Show } from "@mewhhaha/typeclasses/typeclasses";
type Maybe<item> =
| readonly ["Just", item]
| readonly ["Nothing"];
declare const maybe_identity: unique symbol;
interface AsMaybe
extends As<AsMaybe, typeof maybe_identity>, Show<AsMaybe>, Monad<AsMaybe> {
readonly [type_item]: unknown;
readonly [type_data]: Maybe<this[typeof type_item]>;
}
const Maybe = data<AsMaybe>(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<AsMaybe>(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>() creates the callable dictionary, assigns an internal 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.
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
inherits the shared installer and instance accessor, 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. For
Traversable.traverse, collection implementations split empty and non-empty
inputs: the empty branch returns the contextual empty structure, while the
non-empty branch seeds the accumulator from the last mapped value so TypeScript
can infer the output item type before the fold continues.
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]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.
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 {
ap_first,
ap_second,
bind,
empty,
fmap,
fold_map,
foldl,
from_maybe,
guard,
join,
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 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_A–lift_A5, 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, append, 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.
Configured dictionary factories follow the same convention, including
Either.with_left and Tuple.with_monoid.
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:
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 sitesThe 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.
Supported generator control flow includes if, non-fallthrough switch,
classic for, while, do/while, and for...of, including unlabeled
break/continue. Iterables in for...of are materialized once. 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 TypeScript AST
construction and printing in build pipelines while preserving diagnostics for
unsupported Do and Program calls.
Bundlers can use the dependency-free 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 });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>();
const database = reader<"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, a key that is not a literal is rejected, and each key must be declared exactly once.
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]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>();
const label = state<"label", string>();
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 state<string, number>() is rejected at the
declaration.
Declare each key exactly once. The key exists only in the type, so there is
nothing to derive a runtime identity from and every state call mints a fresh
one. Two declarations sharing a key are therefore one cell to the compiler and
two at runtime: a handler the types say discharges both leaves the second one's
lifts pending, and the terminal run throws. This is the one rule the compiler
cannot check for you.
Cells are invisible to get/put and vice versa, so both styles can coexist in
one program. Note that run_state(cell, …) is not rewritten by the source
transformer, so cell programs use the general Effect path rather than a fused
terminal runner.
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", AsArray, string>();
const metrics = writer_cell<"metrics", AsArray, 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>([]),
),
);Note that Writer.with and a cell solve different problems: Writer.with
captures a Monoid identity so pure works on a standalone Writer, and
deliberately shares the base dictionary's runtime kind. A cell mints its own,
which is what gives it a separate handler. Cells follow the same rules as
State cells.
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);
}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.
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; an applicative 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. Use run_task_exit when success, failure, and cancellation
must be handled as values.
Haskell separates applicative independence from monadic dependency:
UserAndScore <$> fetchUser id <*> fetchScore id
fetchUser id >>= fetchProfileThe same distinction matters for Task. Applicative composition can start
independent tasks together, while Do sequences dependent work:
const parallel = Applicative.lift(
(user, score) => ({ user, score }),
from_fn(() => fetch_user(id)),
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 parallel.run();
await dependent.run();This is the same rule as Haskell's Applicative versus Monad: use applicative
style when later operations do not need earlier results, and use monadic style
when they do.
Task provides asynchronous concurrency on one JavaScript isolate. CPU-bound
work can instead 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, parallel Applicative, sequential Monad, MonadError |
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. Calling
a continuation more than once must not accidentally reuse a consumed iterator.
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"]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 exports these built-in-shaped modules under namespaces to avoid helper-name collisions:
import {
array,
async_iterable,
map,
record,
set,
typed_array,
url_search_params,
} from "@mewhhaha/typeclasses";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.
Show.instance(Maybe)({ ... }) validates that every required Show method
exists, installs the collision-free symbol slot, and copies direct fluent
aliases onto the dictionary.
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 benchmark folder is a measurement harness, not part of the correctness gate. 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-env --allow-read --allow-write=/tmp bench/do_vs_program.bench.ts
deno task bench:case-studiesbench/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
the source transformer, including direct Do chains, optimized Effect spines,
and statically visible terminal handlers.
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