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