Chapter 27

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>
Rate this chapter
4.8  / 5  (3 ratings)

💬 Comments