- Book: The TypeScript Type System
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You sit down to write a generic map that works across Array, Option, Promise, and your own Result. You reach for the same shape you have used in Haskell or Scala, and it falls apart. You want interface Functor<F<_>>, but F<_> is not a thing the language understands. You can write F<A> if F is already applied. You cannot write F standing alone, waiting for an argument.
TypeScript has no higher-kinded types: no native way to abstract over a type constructor. There are four well-known workarounds, each with a different cost profile, and most teams pick one without knowing the others exist. You usually do not need any of them, but the menu is worth knowing.
The Parametricity Gap
A type constructor takes a type and returns a type. Array takes string and returns Array<string>. Option takes number and returns Option<number>. The constructor itself is not a type — it is a kind of type, written * -> *. A regular string has kind *. Array has kind * -> *. A function-on-types.
Now write the type of map:
function map<F<_>, A, B>(fa: F<A>, f: (a: A) => B): F<B>
That signature does not parse. F<_> is the syntax we want; the compiler does not have it. TypeScript generics abstract over a fully-applied type, never over the constructor. You can take an Array<A>. You cannot take "a thing that, given an A, gives back a container of A."
Once you accept the gap, the menu of patterns you read about in Haskell and Scala collapses. Functor, Monad, Traversable, Applicative are all interfaces parameterized by a type constructor. You cannot write the interface, so you cannot write generic code against it.
Every workaround below does the same thing. They encode "the constructor F" as something TypeScript can quantify over: a string tag, a phantom slot, or an interface. Then they look the constructor up by that tag. That is defunctionalization. The differences are ergonomics, error messages, and how much framework you swallow.
Why TypeScript Doesn't Have HKTs
This was a deliberate language-design choice, not an oversight. The TypeScript team has discussed HKTs in issue #1213 and the more recent issue #44875, and the position has been consistent. HKTs are expensive to implement, expensive to type-check, and the team's read is that the cost outweighs the slice of users they would serve.
TypeScript is also deliberately structural. HKTs play cleaner in nominal type systems where a constructor has a single canonical name. In a structural world, two structurally-equal interfaces are the same type, and "abstracting over the constructor" gets fuzzy fast.
The pragmatic upshot: as of TypeScript 5.x in 2026, native HKTs are not on a public roadmap. The patterns below are the road. fp-ts has been doing this since the late 2010s, Effect ships its own encoding, and the ecosystem has settled into living without native HKTs.
Workaround 1: fp-ts URI/Kind (Defunctionalization with a Registry)
fp-ts is the original. Version 2.16 is still on npm with sizeable weekly download volume, but maintainer Giulio Canti has been steering users toward Effect, which is publicly framed as the fp-ts successor path. fp-ts v2 is feature-stable rather than abandoned: bug fixes land, the API is frozen, and the recommended path for net-new code is Effect. If you have an fp-ts codebase, it still works. If you are picking today, read the next section first.
The pattern is worth knowing either way — it is the reference implementation every other TypeScript HKT encoding cites.
The trick: pick a string tag for each type constructor and keep a global registry mapping tag to type. Then "the constructor F" is the tag, and "F applied to A" is a lookup in the registry.
// 1. The registry. An interface, so module augmentation
// can extend it from anywhere in the codebase.
interface URItoKind<A> {}
// 2. URIS = the keys of the registry, i.e. the tags
// of every constructor anyone has registered.
type URIS = keyof URItoKind<unknown>
// 3. Kind<F, A> = "F applied to A". Look F up in the
// registry, parameterised by A.
type Kind<F extends URIS, A> = URItoKind<A>[F]
A library author registers a constructor by augmenting the interface:
// In an Option module. Use a discriminated union so TS
// can narrow the Some case without casts.
interface Some<A> { readonly _tag: "Some"; readonly value: A }
interface None { readonly _tag: "None" }
type Option<A> = Some<A> | None
declare module "./HKT" {
interface URItoKind<A> {
readonly Option: Option<A>
}
}
const URI = "Option"
type URI = typeof URI
Now Functor becomes writable, because we abstract over F extends URIS (a string) instead of F<_> (which does not exist):
interface Functor<F extends URIS> {
readonly URI: F
readonly map: <A, B>(fa: Kind<F, A>, f: (a: A) => B) => Kind<F, B>
}
const optionFunctor: Functor<"Option"> = {
URI: "Option",
map: (fa, f) =>
fa._tag === "Some"
? { _tag: "Some", value: f(fa.value) }
: fa,
}
The technique comes from Yallop and White's 2014 paper Lightweight higher-kinded polymorphism, originally for OCaml. fp-ts ports it using interface declaration merging instead of OCaml functors. There is also URItoKind2, URItoKind3, URItoKind4 for higher-arity constructors (Either<E, A>, ReaderTaskEither<R, E, A>), and that ladder is a real cost.
What it buys you: real generic abstract-over-the-constructor code. Functor, Monad, traversals, the whole catalogue.
What it costs you: the registry is global. Every constructor has to pick a unique URI. Two libraries that pick "Result" collide. Type errors mention URItoKind<A>[F] and beginners read them as a wall of indexed types. The arity ceiling is real: beyond URItoKind4 you are mostly out of luck. And every typeclass instance pays the indirection tax, declaring a URI and routing through it.
If you already use fp-ts, you already pay this. If not, the question is whether the next workaround does the same job for less pain.
Workaround 2: Effect's HKT Lite (Cleaner Mechanic, Same Idea)
Effect is the functional / effect-system library by the fp-ts author and contributors. It is in its 3.x line (v3.19 as of early 2026, with a v4 beta announced in February 2026 — see Effect releases) and has eaten most of fp-ts's mindshare for net-new FP work in TypeScript. Its @effect/typeclass package ships the Functor, Monad, Applicative catalogue without the URI registry.
The mechanic is still defunctionalization, but Effect uses a per-typeclass interface with a phantom slot, no global table. Roughly:
// Simplified — the real definition has more slots for
// In, Out1, Out2, R, E and other Effect-specific channels.
interface TypeLambda {
readonly Target: unknown
readonly Type: unknown
}
type Kind<F extends TypeLambda, A> =
(F & { readonly Target: A })["Type"]
interface Functor<F extends TypeLambda> {
readonly map: <A, B>(
fa: Kind<F, A>,
f: (a: A) => B,
) => Kind<F, B>
}
this["Target"] is the type-level equivalent of a placeholder slot. When you intersect F & { Target: A } and read its Type, the constructor's lambda fills Target with A and returns the applied result. A constructor like Option provides its own TypeLambda:
interface OptionTypeLambda extends TypeLambda {
readonly Type: Option<this["Target"]>
}
const FunctorOption: Functor<OptionTypeLambda> = {
map: (fa, f) =>
fa._tag === "Some"
? { _tag: "Some", value: f(fa.value) }
: fa,
}
The shift: the registry moves from a global interface to a per-constructor TypeLambda. Kind<F, A> reads the type by setting Target and pulling the result. No shared namespace to collide in. New arity means new slots on the same lambda interface, not a new URItoKindN rung.
In 2026, when you reach for a typeclass library in TypeScript, this is the encoding you meet. Errors are still dense: (F & { Target: A })["Type"] shows up in failure messages. But they are local, not coupled to a global table. Effect lead Michael Arnaldi wrote two long-form posts on why the encoding evolved this way: "Encoding HKTs in TS4.1" and "Encoding HKTs in TypeScript (Once Again)". Both are the source material for this section.
If your team is already on Effect for its runtime concerns (fiber concurrency, error channel, dependency injection), @effect/typeclass slots in for free. If you are not on Effect, the encoding can be lifted on its own (hkt-ts and hkt-core are standalone), but at that point you are picking up a small framework anyway.
Workaround 3: Defunctionalization Without a Registry
Both workarounds above are flavours of defunctionalization. The general technique, from John Reynolds' 1972 paper, is older than typeclass-style FP: replace higher-order functions with first-order ones by giving each a tag and a dispatch table. Applied at the type level, every "type function" gets a tag and a switch.
You do not need a typeclass library for it. For self-contained type-level work (a domain DSL, a type-level state machine, a typed path or query parser), defunctionalization alone is enough.
// A type-level "function" is an interface with an Input
// slot and an Output computation.
interface TypeFn {
readonly Input: unknown
readonly Output: unknown
}
// Apply: feed an Input to a TypeFn and read its Output.
type Apply<F extends TypeFn, X> =
(F & { readonly Input: X })["Output"]
// Concrete type-level functions:
interface Id extends TypeFn {
readonly Output: this["Input"]
}
interface ToArray extends TypeFn {
readonly Output: this["Input"][]
}
interface Stringify extends TypeFn {
readonly Output: this["Input"] extends number
? `${this["Input"]}`
: never
}
type A = Apply<ToArray, string> // string[]
type B = Apply<Stringify, 42> // "42"
That is the whole pattern. Now you can build a type-level Map:
type MapTuple<T extends readonly unknown[], F extends TypeFn> =
T extends readonly [infer H, ...infer R]
? readonly [Apply<F, H>, ...MapTuple<R, F>]
: readonly []
type C = MapTuple<[1, 2, 3], ToArray>
// readonly [1[], 2[], 3[]]
This is the trick library code uses internally. HOTScript is essentially a standard library of TypeFns.
When does it fit? When you need a small piece of higher-kinded behaviour for one domain (a query DSL, a path-parameter parser, a typed event bus) and do not want to import a typeclass framework. The advantage over the URI registry is that you own all of it. The disadvantage versus Effect's encoding is that you build the typeclass infrastructure yourself if you want one. For the cases where you do not, which is most cases, that is the point.
Workaround 4: Branded "Type-Functions" for One-Off Cases
Sometimes you only need one or two pieces of higher-kinded behaviour, and even the bare-bones defunctionalization above feels heavy. Lightest option: give each type-function a brand and dispatch with conditional types.
declare const __tag: unique symbol
type Tagged<K extends string, T> = T & {
readonly [__tag]: K
}
// "F" is just a string tag.
type Container<F extends string, A> =
F extends "Array" ? A[] :
F extends "Option" ? { _tag: "Some"; value: A } | { _tag: "None" } :
F extends "Promise" ? Promise<A> :
never
type X = Container<"Array", number> // number[]
type Y = Container<"Option", string> // ...Option of string
type Z = Container<"Promise", boolean> // Promise<boolean>
Now you can write a map-like signature:
function map<F extends "Array" | "Option" | "Promise", A, B>(
tag: F,
fa: Container<F, A>,
f: (a: A) => B,
): Container<F, B> {
// Dispatch at runtime. Each arm matches the type-level
// case in Container<F, A>.
switch (tag) {
case "Array":
return (fa as A[]).map(f) as Container<F, B>
case "Option": {
const o = fa as { _tag: "Some"; value: A } | { _tag: "None" }
return (
o._tag === "Some"
? { _tag: "Some", value: f(o.value) }
: o
) as Container<F, B>
}
case "Promise":
return (fa as Promise<A>).then(f) as Container<F, B>
default: {
const _exhaustive: never = tag
throw new Error(`unreachable: ${_exhaustive}`)
}
}
}
It is not generic in the typeclass sense. It is closed: every constructor you support is enumerated in the union and in the runtime switch. Adding a new one means editing the file. That is fine with three of them, stable requirements, and no appetite for a framework's vocabulary.
Real fits for this pattern: a small validation library that wants Ok<A> | Err<E> and Some<A> | None to share a map; a typed effect helper used by two services; a route-builder abstracting over Promise, Observable, and a custom LazyTask. All bounded, all under 500 lines.
The brand piece earns its keep when you want each tag to be a nominal type, not just a string. If "Array" and "List" are both string, you risk crossing wires. A Tagged<"Array", T> and a Tagged<"List", T> cannot be confused — same trick as branded ids, applied to type-function tags.
Where it falls down: as soon as you want open extension, multi-arity constructors, or a real typeclass hierarchy, you are reinventing fp-ts. Switch to Effect's encoding then. For one or two HKT-shaped problems in a vanilla codebase, this is the cheapest way through.
A Decision Rule
Pick the smallest workaround that solves the problem. In rough order of cost:
-
Branded type-functions — fewest constructors, no framework, closed set, runtime under your control. Right for "I need
mapover myResultandPromise, that is it." A couple of types and a switch buy you a low ceiling. -
Defunctionalization without a registry — mostly type-level work, not runtime instances. Type-level parsers, path DSLs, route builders, typed query languages. Right for "I want HOTScript's pattern for my own domain." Cost: a small set of
TypeFninterfaces. Ceiling: your patience for type-level code. -
Effect's HKT lite (
@effect/typeclass) — you want typeclasses (Functor,Monad,Applicative,Traversable) that compose. Right for "we are building a non-trivial functional layer and we will take on the framework." You pay the Effect dependency, the encoding's verbosity, and error messages that take time to read fluently. In return you get the most headroom of any option here. - fp-ts URI/Kind — only if you already have an fp-ts codebase. New code should use Effect; the migration story is documented in the Effect-vs-fp-ts page linked above. You inherit fp-ts's existing rough edges, and that is where they stay.
Two anti-patterns. One: building a custom typeclass framework on plain defunctionalization because you "did not want a dependency." If you want Functor, Monad, and Traversable composing in production, take the dependency — the rabbit hole is deeper than it looks. Two: forcing every part of a codebase into HKT-shaped abstractions. If only one module needs map over a few constructors, do that one module with workaround 4 and leave the rest alone.
When Two Methods Beat A Typeclass
The ergonomic pain of the workarounds is real. Type errors get worse, the build slows down, and the next engineer needs a pre-read before they can review a PR. That is a tax you pay for genuinely generic, abstract-over-the-container behaviour.
A lot of code that looks like it wants HKTs wants something simpler. If you want to map over a Result, write Result.map. If you want both Result and Option to have map, write two methods, name them the same, move on. If you want a typed query DSL, that is the defunctionalization case, not the typeclass case. If you want a validator that works for forms and API edges, branded constructors plus a parser beat a Functor instance.
Reach for HKTs when you have a real hierarchy of behaviours (Functor, Applicative, Monad, Traversable) and want generic code over all of them. That is when the cost pays back. Anywhere else, the simpler shape wins.
Forward Motion
The ecosystem has stopped waiting on native HKTs, and the workarounds are mature. The deeper move is recognising when a problem actually wants typeclasses versus when it wants a simpler tool. These patterns let you encode things the language will not encode for you, and using them where they do not fit is one of the most expensive shapes a TypeScript codebase can take.
Open the file your team has been arguing about. Find the place where someone wanted Functor<F<_>>. Ask whether the encoding above is the right move, or whether two named methods and a clearer domain type would do the same work for a tenth of the cost.
If this was useful
The deeper material on type-level programming, conditional types, mapped types, template literals, and the encodings these workarounds build on lives in The TypeScript Type System. Pick the entry point that matches where you are.
Books 1 and 2 are the core path. If you came from JVM or PHP, books 3 or 4 substitute for book 1. Book 5 is for anyone shipping TypeScript at work.
The TypeScript Library — 5-book collection:
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — From Generics to DSL-Level Types — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — variance, null safety, sealed-to-unions, coroutines-to-async/await
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — sync-to-async paradigm, generics, discriminated unions
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
All five books ship in ebook, paperback, and hardcover.

Top comments (0)