🚀AdvancedAdvanced Design Patterns
Simulating Higher-Kinded Types
Simulate HKT (type constructors as first-class values) in TypeScript using interface lookup maps, enabling truly generic abstractions like Functor and Monad patterns.
Overview
**Higher-kinded types (HKT)** are type constructors that take other types as arguments. In Haskell, you can write `Functor f` where `f` is itself a type constructor like `Maybe` or `List`. TypeScript does not natively support HKT because type parameters cannot themselves be generic.
However, a workaround using **interface declaration merging and lookup maps** (popularized by fp-ts) allows you to simulate HKT. The trick: register your type constructors in a global `URItoKind` interface, then use a `Kind<URI, A>` alias to look up the concrete type.
This enables fully generic abstractions: a `map` function that works on ANY Functor (Array, Option, Either, Task) without knowing which specific one it is at the point of definition. Real TypeScript functional programming libraries (fp-ts, Effect) use exactly this technique.
Key Points
- •TypeScript cannot use a type parameter as a type constructor: `<F, A>(fa: F<A>)` is not valid
- •HKT simulation: a global registry `URItoKind<A>` maps string keys to concrete generic types
- •`type URIS = keyof URItoKind<unknown>` - all registered type constructors
- •`type Kind<F extends URIS, A> = URItoKind<A>[F]` - look up the concrete type
- •Typeclass instances: implement `Functor<F>` by providing a `map` for your specific F
- •This enables truly generic algorithms that work on any registered type constructor
- •fp-ts and Effect use this exact pattern for their category theory abstractions
Examples
The HKT registry pattern
// Step 1: A global registry interface - each module augments this
interface URItoKind<A> {
// 'Array' -> A[]
// 'Option' -> Option<A>
// Add more by augmenting this interface
}
// The URI type is all registered keys
type URIS = keyof URItoKind<unknown>;
// Kind<F, A> looks up what F<A> is
type Kind<F extends URIS, A> = URItoKind<A>[F];
// Step 2: Register the Array type constructor
declare module './hkt' { // augment the module containing URItoKind
interface URItoKind<A> {
'Array': A[];
}
}
// Step 3: Define a typeclass (Functor) in terms of HKT
interface Functor<F extends URIS> {
readonly URI: F;
map<A, B>(fa: Kind<F, A>, f: (a: A) => B): Kind<F, B>;
}
// Step 4: Implement Functor for Array
const ArrayFunctor: Functor<'Array'> = {
URI: 'Array',
map<A, B>(fa: A[], f: (a: A) => B): B[] {
return fa.map(f);
},
};
// Generic algorithm that works on any Functor
function doubleMap<F extends URIS>(
F: Functor<F>,
fa: Kind<F, number>
): Kind<F, number> {
return F.map(fa, x => x * 2);
}
const doubled = doubleMap(ArrayFunctor, [1, 2, 3]);
console.log(doubled); // [2, 4, 6]Option type as a registered HKT
// Option type
type Option<A> = { _tag: 'None' } | { _tag: 'Some'; value: A };
const none: Option<never> = { _tag: 'None' };
const some = <A>(a: A): Option<A> => ({ _tag: 'Some', value: a });
// Register Option in the HKT registry
// (in real code: declare module 'your-hkt-module')
interface URItoKind<A> {
'Option': Option<A>;
}
type URIS = keyof URItoKind<unknown>;
type Kind<F extends URIS, A> = URItoKind<A>[F];
interface Functor<F extends URIS> {
URI: F;
map<A, B>(fa: Kind<F, A>, f: (a: A) => B): Kind<F, B>;
}
// Functor instance for Option
const OptionFunctor: Functor<'Option'> = {
URI: 'Option',
map<A, B>(fa: Option<A>, f: (a: A) => B): Option<B> {
return fa._tag === 'None' ? none : some(f(fa.value));
},
};
// Generic lift: apply f only if there is a value
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 doubleOption = lift(OptionFunctor, double);
console.log(doubleOption(some(5))); // Some(10)
console.log(doubleOption(none)); // NoneApplicative and Monad simulation
// Building on Functor: Applicative adds 'of' and 'ap'
interface Applicative<F extends URIS> extends Functor<F> {
of<A>(a: A): Kind<F, A>;
ap<A, B>(fab: Kind<F, (a: A) => B>, fa: Kind<F, A>): Kind<F, B>;
}
// Monad adds 'chain' (flatMap)
interface Monad<F extends URIS> extends Applicative<F> {
chain<A, B>(fa: Kind<F, A>, f: (a: A) => Kind<F, B>): Kind<F, B>;
}
// Array is a Monad
const ArrayMonad: Monad<'Array'> = {
URI: 'Array',
map: (fa, f) => fa.map(f),
of: (a) => [a],
ap: (fab, fa) => fab.flatMap(f => fa.map(f)),
chain: (fa, f) => fa.flatMap(f),
};
// Generic flatMap using any Monad
function flatMapTwice<F extends URIS, A, B>(
M: Monad<F>,
fa: Kind<F, A>,
f: (a: A) => Kind<F, B>
): Kind<F, B> {
return M.chain(M.chain(fa, f), x => M.of(x));
}
const result = flatMapTwice(
ArrayMonad,
[1, 2, 3],
n => [n, n * 10]
);
console.log(result); // [1, 10, 2, 20, 3, 30]
interface URItoKind<A> { 'Array': A[] }
type URIS = keyof URItoKind<unknown>;
type Kind<F extends URIS, A> = URItoKind<A>[F];
interface Functor<F extends URIS> { URI: F; map<A,B>(fa:Kind<F,A>, f:(a:A)=>B):Kind<F,B>; }Common Mistakes
- Trying to write `function f<F<_>, A>(fa: F<A>)` directly - TypeScript does not support this syntax; the URI registry is the required workaround
- Forgetting to augment the registry interface when adding a new type constructor - without the augmentation, the new type cannot be used with generic Functor/Monad functions
- Over-engineering with HKT for practical application code: this pattern is for library authors. For application code, explicit types and simple generic functions are more readable and maintainable
Note
HKT simulation in TypeScript has a steep learning curve but enables genuinely reusable abstractions. The fp-ts library (and its successor Effect) demonstrate this at scale - their entire ecosystem is built on the URI registry pattern. Study their source to see how this technique handles dozens of type constructors across hundreds of modules.
Exercises
Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.
Loading editor…
Completed: 0 / 1 exercises
Knowledge Check
Question 1 of 4