Simulating Higher-Kinded Types in TypeScript
What Higher-Kinded Types Are
In Haskell, Functor is defined as:
class Functor f where
fmap :: (a -> b) -> f a -> f b
Here f is a type constructorโit takes a type and produces a type. Maybe, [] (List), and Either e can all play the role of f. The critical point: f itself is not a concrete type; it's a "function on types." This is a higher-kinded type.
TypeScript has generics, but not higher-kinded generics:
// This works
interface Functor<A> {
map<B>(f: (a: A) => B): Functor<B>;
}
// This does not
interface Functor<F<_>> { // โ Syntax error โ TypeScript doesn't support this
map<A, B>(fa: F<A>, f: (a: A) => B): F<B>;
}
You cannot pass Array, Promise, or Option as type constructor arguments. Writing truly generic functional abstractions (Functor, Monad, Applicative) becomes impossibleโwithout a workaround.
Defunctionalization: The Core Trick
This technique comes from programming language theory, originally used to convert higher-order functions to first-order ones. fp-ts brought it into TypeScript's type system.
Step 1: A URI Registry
// Each type constructor registers a unique URI string
// Interface merging lets registration happen across files/modules
interface URItoKind<A> {
// Empty by default โ modules extend this
}
// The union of all registered URIs
type URIS = keyof URItoKind<unknown>;
// The core mapping: URI + type arg โ concrete type
type Kind<F extends URIS, A> = URItoKind<A>[F];
Step 2: Register Type Constructors
// Register Array
declare module "./hkt" {
interface URItoKind<A> {
readonly Array: Array<A>;
}
}
// Register Option (a custom type)
type Option<A> = { _tag: "None" } | { _tag: "Some"; value: A };
declare module "./hkt" {
interface URItoKind<A> {
readonly Option: Option<A>;
}
}
// Verify Kind resolves correctly
type K1 = Kind<"Array", number>; // number[] โ
type K2 = Kind<"Option", string>; // Option<string> โ
Step 3: Generic Interfaces
interface Functor<F extends URIS> {
readonly URI: F;
map<A, B>(fa: Kind<F, A>, f: (a: A) => B): Kind<F, B>;
}
interface Apply<F extends URIS> extends Functor<F> {
ap<A, B>(fab: Kind<F, (a: A) => B>, fa: Kind<F, A>): Kind<F, B>;
}
interface Monad<F extends URIS> extends Apply<F> {
of<A>(a: A): Kind<F, A>;
chain<A, B>(fa: Kind<F, A>, f: (a: A) => Kind<F, B>): Kind<F, B>;
}
Concrete Implementations
Array Monad
const arrayMonad: Monad<"Array"> = {
URI: "Array",
map: <A, B>(fa: Array<A>, f: (a: A) => B): Array<B> => fa.map(f),
ap: <A, B>(fab: Array<(a: A) => B>, fa: Array<A>): Array<B> =>
fab.flatMap((f) => fa.map(f)),
of: <A>(a: A): Array<A> => [a],
chain: <A, B>(fa: Array<A>, f: (a: A) => Array<B>): Array<B> => fa.flatMap(f),
};
const r1: Array<string> = arrayMonad.map([1, 2, 3], (n) => n.toString());
// ["1", "2", "3"] โ
Option Monad
type None = { readonly _tag: "None" };
type Some<A> = { readonly _tag: "Some"; readonly value: A };
type Option<A> = None | Some<A>;
const none: None = { _tag: "None" };
const some = <A>(value: A): Some<A> => ({ _tag: "Some", value });
const optionMonad: Monad<"Option"> = {
URI: "Option",
map: <A, B>(fa: Option<A>, f: (a: A) => B): Option<B> =>
fa._tag === "None" ? none : some(f(fa.value)),
ap: <A, B>(fab: Option<(a: A) => B>, fa: Option<A>): Option<B> => {
if (fab._tag === "None") return none;
if (fa._tag === "None") return none;
return some(fab.value(fa.value));
},
of: <A>(a: A): Option<A> => some(a),
chain: <A, B>(fa: Option<A>, f: (a: A) => Option<B>): Option<B> =>
fa._tag === "None" ? none : f(fa.value),
};
Generic Algorithms That Work Across All Monads
The payoff: write one function that works for any registered Functor or Monad.
lift: Elevate a Function into a Functor Context
function lift<F extends URIS, A, B>(
F: Functor<F>,
f: (a: A) => B
): (fa: Kind<F, A>) => Kind<F, B> {
return (fa) => F.map(fa, f);
}
const double = (n: number) => n * 2;
const liftedToArray = lift(arrayMonad, double);
liftedToArray([1, 2, 3]); // [2, 4, 6] type: Array<number> โ
const liftedToOption = lift(optionMonad, double);
liftedToOption(some(5)); // Some(10) type: Option<number> โ
liftedToOption(none); // None โ
sequence: Flip F[] into F<A[]>
function sequence<F extends URIS, A>(
M: Monad<F>,
fas: Kind<F, A>[]
): Kind<F, A[]> {
return fas.reduce(
(acc: Kind<F, A[]>, fa: Kind<F, A>) =>
M.chain(acc, (as: A[]) => M.map(fa, (a: A) => [...as, a])),
M.of([] as A[])
);
}
// Under Array Monad: Cartesian product
const cartesian = sequence(arrayMonad, [[1, 2], [10, 20]]);
// [[1, 10], [1, 20], [2, 10], [2, 20]] โ
// Under Option Monad: all-or-nothing
const allSome = sequence(optionMonad, [some(1), some(2), some(3)]);
// Some([1, 2, 3]) โ
const anyNone = sequence(optionMonad, [some(1), none, some(3)]);
// None โ (one None poisons the whole computation)
Same function, different behaviorโbecause chain for Array is flatMap and chain for Option short-circuits on None. This is polymorphism in the functional sense.
HKT2: Two Type Parameters
Either<E, A> needs a two-parameter version:
interface URItoKind2<E, A> { }
type URIS2 = keyof URItoKind2<unknown, unknown>;
type Kind2<F extends URIS2, E, A> = URItoKind2<E, A>[F];
type Either<E, A> = { _tag: "Left"; left: E } | { _tag: "Right"; right: A };
declare module "./hkt2" {
interface URItoKind2<E, A> {
readonly Either: Either<E, A>;
}
}
interface Monad2<F extends URIS2> {
readonly URI: F;
of<E, A>(a: A): Kind2<F, E, A>;
map<E, A, B>(fa: Kind2<F, E, A>, f: (a: A) => B): Kind2<F, E, B>;
chain<E, A, B>(fa: Kind2<F, E, A>, f: (a: A) => Kind2<F, E, B>): Kind2<F, E, B>;
}
const eitherMonad: Monad2<"Either"> = {
URI: "Either",
of: <E, A>(a: A): Either<E, A> => ({ _tag: "Right", right: a }),
map: <E, A, B>(fa: Either<E, A>, f: (a: A) => B): Either<E, B> =>
fa._tag === "Left" ? fa : { _tag: "Right", right: f(fa.right) },
chain: <E, A, B>(fa: Either<E, A>, f: (a: A) => Either<E, B>): Either<E, B> =>
fa._tag === "Left" ? fa : f(fa.right),
};
fp-ts and Effect in Production
fp-ts is built on exactly this pattern:
import * as O from "fp-ts/Option";
import * as A from "fp-ts/Array";
import { pipe } from "fp-ts/function";
const result = pipe(
O.some(42),
O.map((n) => n * 2),
O.chain((n) => n > 0 ? O.some(n.toString()) : O.none),
O.getOrElse(() => "default")
);
// result: "84" type: string โ
Effect-ts extends the model to a full effect system:
import { Effect } from "effect";
// Effect<R, E, A>: requires environment R, may fail with E, succeeds with A
const program = Effect.gen(function* () {
const config = yield* Effect.service(ConfigService);
const user = yield* fetchUser(config.apiUrl, 1);
return user.name;
});
// TypeScript infers the full R, E, A signature
The generator syntax (yield*) is syntactic sugar for chainโit's the Monad do-notation pattern, making sequential monadic operations look like imperative code.
When to Use This Pattern
| Scenario | Use HKT | Skip HKT |
|---|---|---|
| Writing an fp-ts/effect-style library | Required | โ |
| Generic algorithms polymorphic over container types | Worth it | โ |
| Container types are extensible by third parties | Required | โ |
| Regular business logic | โ | Use plain generics |
| Only 2โ3 container types | โ | Function overloads are simpler |
| Team unfamiliar with FP concepts | โ | Learning cost too high |
| Performance-critical hot paths | โ | Virtual dispatch overhead |
The core question: do you need algorithms that are polymorphic over an open set of container types? If yes, use HKT. If the containers are fixed and known, use overloads or conditional types.
Anti-Patterns
| Anti-Pattern | Problem |
|---|---|
| Wrapping everything in HKT abstractions | Code becomes unreadable; colleagues can't maintain it |
URI string typos ("array" vs "Array") |
No type checking on URI strings; Kind<"array", number> silently becomes unknown |
Using Kind<F, A> before registering the URI |
Returns unknown; type safety vanishes |
Deep HKT chains without pipe |
M.chain(M.chain(M.map(...))) nesting becomes unreadable |
Summary
| Concept | Role | Implementation |
|---|---|---|
| URI registry | Map type constructors to strings | interface URItoKind<A> + declaration merging |
Kind<F, A> |
Recover concrete type from URI + arg | Indexed access URItoKind<A>[F] |
Functor<F> |
Generic mapping | map: (fa: Kind<F,A>, f: AโB) => Kind<F,B> |
Monad<F> |
Sequential composition | chain + of |
sequence |
Flip container nesting | Combines chain, map, reduce |
| HKT2 | Two-parameter containers | URItoKind2<E, A> + Kind2<F, E, A> |