· 5 min read
Typeclasses with the grain
Functional programming in TypeScript becomes elegant when Haskell's laws are translated through native JavaScript shapes instead of copied as foreign syntax.

Functional programming in TypeScript gets ugly the moment a library asks you to pretend the host language is Haskell. Higher-kinded encodings leak into your application types, half the import list ends up being point-free helpers, and ordinary JavaScript values disappear under a vocabulary that got transplanted without translation. None of that is the algebra's fault.
@mewhhaha/typeclasses interests me because it makes the opposite wager: import the typeclass semantics, then let TypeScript decide how they should feel under the hand.
const parsed = Right("42") .bind((text) => from_number(Number.parseInt(text, 10))) .map((value) => value + 1);const label = parsed.match({ Left: (error) => "error:" + error, Right: (value) => "value:" + value,});The surface belongs to the host#
The chain above looks like TypeScript. A value carries methods your editor can discover, callbacks keep their arrow syntax, and match turns a tagged tuple into an exhaustive expression you can read left to right without first learning a wall of operators.
The package still exposes a function-first prelude with fmap, pure, bind, and traverse, and both surfaces dispatch through the same dictionaries — so picking the fluent methods costs you nothing. You're reaching the same operations from a friendlier angle, not settling for a beginner façade pasted over a second implementation.
Purity of notation is a poor goal for a multi-paradigm language anyway. Sometimes a chain keeps the value visible, sometimes a standalone combinator makes generic code clearer, and the pleasant choice is usually whichever one lets the surrounding program keep its existing grammar.
The ugly part is allowed to be ugly#
The original typeclass proposal made ad-hoc polymorphism systematic, and in Haskell the compiler elaborates a constraint into the right instance dictionary for you. TypeScript has no native higher-kinded types — the request has been an open language-design issue since 2014 — so a library has to build the missing joints itself.
interface AsMaybe extends As<AsMaybe>, Monad<AsMaybe> { readonly [type_item]: unknown; readonly [type_data]: Maybe<this[typeof type_item]>;}const Maybe = data<AsMaybe>( union(["Just", $slot], ["Nothing"]),);The phantom symbol slots are what let the varying item type recover the raw Maybe shape. The callable Maybe value doubles as a runtime dictionary, wrapped values inherit implementations through a shared prototype, and canonical methods live in symbol-keyed slots so unrelated typeclasses can reuse a method name without colliding.
This is dense code — recursive constraints, declaration plumbing, overloads, prototype work — and application authors should rarely have to look at any of it. Good abstraction doesn't eliminate machinery; it decides where the machinery is allowed to be seen.
TypeScript's structural type system and module augmentation help at the boundary here. A local typeclass can extend an open data dictionary, install a runtime implementation, and get both generic and fluent access. The library covers for the language's missing feature by leaning on one it actually has.
Borrow syntax, account for cost#
Generator-based Do notation is the most theatrical part of the package. It uses JavaScript's iterator protocol to make sequential binding look like ordinary assignment:
const saved = Do(function* () { const text = yield* parse(input); const value = yield* validate(text); return yield* save(value);});The syntax works because the dependency stays visible: parsing produces the text validation needs, validation produces the value saving needs. When the inputs are independent, an applicative expression is still the better shape, and nothing here blurs that line.
The runtime technique does have a cost. A multi-shot monad like a list may replay the generator for each branch, so code before a yield can run more than once. The repository says so up front, then offers an optional build-time transformer that lowers supported blocks to direct method chains. Unsupported shapes are left alone and produce diagnostics, and detection is anchored to package imports rather than to any local function that happens to be named Do.
That narrowness is the part I keep coming back to. A transform should compress ceremony, not grow into a private language whose execution model only the build step understands. The source can differ from the runtime, but a reader should still be able to see how the two relate.
The blank cells matter#
The project is most convincing where it refuses an instance. Look at its JavaScript-shape matrix and notice what's missing: every empty cell marks a place where a familiar abstraction would have made a false promise.
| Shape | Functor | Applicative | Monad | Semigroup · Monoid | Foldable | Why the blank |
|---|---|---|---|---|---|---|
| Validation | ✓ | ✓ | Monad would break independent error accumulation. | |||
| Typed arrays | ✓ | A general map can't keep the numeric representation. | ||||
| Map and Record | ✓ | ✓ | ✓ | No honest pure for an unknown key space. | ||
| Task | ✓ | ✓ | ✓ | None: parallel and sequential composition stay distinct. |
Validation has an accumulating Applicative but no Monad, because dependent binding would throw away the independent error accumulation the type exists for. Maps and records get useful semigroups and monoids but no arbitrary pure — there's no honest way to conjure one for an unknown key space. Typed arrays can fold, but a general map can't promise the output type still fits the underlying numeric representation.
Task keeps both abstractions and makes their difference operational: applicative composition starts independent work together, while monadic composition sequences work whose next step needs the previous result. For once the vocabulary surfaces an actual scheduling decision.
That restraint is where the experiment stops feeling like Haskell copied into TypeScript and starts feeling like language design. The laws decide what the API may promise, JavaScript decides what the runtime can deliver, and the editor gets a say too, since discoverability shaped the fluent surface. What comes out still has a functional accent, but it sounds like TypeScript.