how fp-ts uses the URI pattern to close a language gap
TypeScript allows generics over concrete types, but not generics over type constructors themselves. A function that works equally well for any container type such as Array, Option, or Promise cannot be written directly. Through a technique called defunctionalization, known as the URI pattern from fp-ts, this missing feature can still be simulated in a practical way.
Table of Contents
- 1. What higher kinded types are and why TypeScript lacks them
- 2. The concrete problem: a generic functor over container types
- 3. Simulation through defunctionalization: the URI pattern
- 4. Practical example: building a generic Functor interface
- 5. HKT simulation for Option and Either-like containers
- 6. Limits of the simulation and when it is not worth it
- 7. Practical alternatives without a full HKT simulation
- 8. How fp-ts uses the URI pattern in practice
- 9. Direct generics vs. HKT simulation vs. codegen compared
- 10. Summary
- 11. FAQ
1. What higher kinded types are and why TypeScript lacks them
A normal generic type such as Array<T> is parameterized over a concrete type T. Higher kinded types go one step further: they parameterize over a type constructor itself, something like Array, Promise, or Option, without yet fixing which concrete type sits inside it. In languages such as Haskell or Scala you write signatures like Functor<F>, where F itself is a type constructor with an open slot, something like F<_>.
TypeScript's type system does not allow this kind of abstraction directly. A generic parameter in TypeScript must always be a concrete type, never another generic type constructor with an open slot. You cannot write function map<F, A, B>(fa: F<A>, f: (a: A) => B): F<B> and expect F to be usable equally for Array, Promise, or a custom Option class. This limitation is known as "no support for higher kinded types" and affects virtually every structurally typed type system without explicit kind polymorphism.
There is still a practical solution that has been in production use in the functional TypeScript library fp-ts for years: simulating higher kinded types through a technique called defunctionalization, colloquially known as the URI pattern. This article explains the problem in detail, shows the simulation step by step, and clearly names its boundaries.
2. The concrete problem: a generic functor over container types
To make the problem tangible, consider a map function that works structurally identically for Array<A>, Option<A>, and Either<E, A>: it takes a container and a transformation function and applies the function to the contained value without changing the container itself. This operation is called a Functor in functional programming, and each of these three implementations would be trivial on its own.
The actual goal, however, is a single generic signature that works for arbitrary container types, without reimplementing a separate map function for every container. This is exactly what requires higher kinded types, because the container type itself must become a generic parameter. Without this abstraction, the only choice remaining is code duplication for every container type or giving up type safety via any.
// Three structurally identical implementations, no shared abstraction
function mapArray<A, B>(fa: A[], f: (a: A) => B): B[] {
return fa.map(f);
}
function mapOption<A, B>(fa: A | undefined, f: (a: A) => B): B | undefined {
return fa === undefined ? undefined : f(fa);
}
// The goal: a single generic "map" that works for ANY container type F
// function map<F, A, B>(fa: F<A>, f: (a: A) => B): F<B>
// This is NOT valid TypeScript — F cannot be a type constructor parameter
3. Simulation through defunctionalization: the URI pattern
The solution used by fp-ts and similar libraries is called defunctionalization: instead of passing the type constructor directly as a generic parameter, it is represented by a unique string literal type, the so-called URI. A global registry, typically an interface named URItoKind<A>, maps every URI to the actual type parameterized with A. A generic function then does not work with the type constructor itself, but with the URI as a placeholder, looking up the real type through the registry.
This indirection is the core of the higher kinded types simulation: instead of writing F<A>, you write Kind<F, A>, where Kind is a lookup type that looks up the URI F in the registry and returns the matching concrete type. New container types are supported by extending the registry with a new entry via declaration merging, without touching existing code.
// The registry: maps a URI (string literal) to the actual generic type
interface URItoKind<A> {
Array: A[];
Option: A | undefined;
// new container types are added here via declaration merging
}
type URIS = keyof URItoKind<unknown>;
// "Kind" performs the lookup: URI + type parameter -> concrete type
type Kind<URI extends URIS, A> = URItoKind<A>[URI];
// Now map() can be written generically over the URI, not over F directly
function map<URI extends URIS, A, B>(
uri: URI,
fa: Kind<URI, A>,
f: (a: A) => B
): Kind<URI, B> {
// implementation dispatches based on uri at runtime
throw new Error("dispatch implementation omitted for brevity");
}
4. Practical example: building a generic Functor interface
With the Kind mechanism from the previous section, you can define a real, reusable Functor interface that requires its own but typesafe signature-compatible implementation for every URI. Every concrete instance of this interface, for example for Array or Option, implements map with the matching runtime logic, while the type signature via Kind<URI, A> stays identical across all instances.
The practical benefit shows up as soon as you write generic helper functions that only accept a Functor instance as a parameter, for example a function that applies two transformations in sequence. Such a helper function does not need to know whether it works with Array, Option, or a custom-written Result type, as long as a matching Functor instance exists.
interface Functor<URI extends URIS> {
map<A, B>(fa: Kind<URI, A>, f: (a: A) => B): Kind<URI, B>;
}
const arrayFunctor: Functor<"Array"> = {
map: (fa, f) => fa.map(f),
};
const optionFunctor: Functor<"Option"> = {
map: (fa, f) => (fa === undefined ? undefined : f(fa)),
};
// A generic helper that works with ANY Functor instance
function mapTwice<URI extends URIS, A>(
F: Functor<URI>,
fa: Kind<URI, A>,
f: (a: A) => A
): Kind<URI, A> {
return F.map(F.map(fa, f), f);
}
mapTwice(arrayFunctor, [1, 2, 3], (x) => x * 2); // [4, 8, 12]
5. HKT simulation for Option and Either-like containers
Containers with more than one type parameter, for example Either<E, A> with an error type and a success type, require a slight extension of the pattern, because the registry then has to be parameterized over two parameters instead of just A. fp-ts solves this with its own naming convention, URItoKind2<E, A> for two-parameter type constructors, in addition to URItoKind<A> for single-parameter ones. This split by number of type parameters is necessary because TypeScript interfaces cannot be unified across a variable number of generic parameters.
In practice this means a complete higher kinded types simulation needs several parallel registries, one per number of type parameters, which makes the technique powerful but noticeably more complex than a simple generic function. For the vast majority of use cases in business applications, the single-parameter variant from sections 3 and 4 is already sufficient.
// Two-parameter container types need a separate registry keyed by arity
interface URItoKind2<E, A> {
Either: { _tag: "Left"; left: E } | { _tag: "Right"; right: A };
}
type URIS2 = keyof URItoKind2<unknown, unknown>;
type Kind2<URI extends URIS2, E, A> = URItoKind2<E, A>[URI];
function mapEither<E, A, B>(
fa: Kind2<"Either", E, A>,
f: (a: A) => B
): Kind2<"Either", E, B> {
return fa._tag === "Left" ? fa : { _tag: "Right", right: f(fa.right) };
}
6. Limits of the simulation and when it is not worth it
The defunctionalization technique has real limits. First, it creates an indirect type layer that is hard to read for developers without functional programming experience, because Kind<URI, A> does not show an immediate, self-explanatory relation to the actual type. Second, the registry does not scale arbitrarily: every number of type parameters needs its own registry interface family, which quickly becomes unwieldy for three or four parameter type constructors.
Third, and this is the most important practical point: most teams that do not use a dedicated functional programming library like fp-ts rarely benefit from building their own higher kinded types simulation from scratch. The maintenance cost of the registry and the kind lookups often exceeds the benefit over simpler alternatives such as separate, deliberately duplicated functions per container type in ordinary business applications.
7. Practical alternatives without a full HKT simulation
For many use cases, a much simpler approach is enough: a generic function parameterized directly over the concrete element type instead of over the container type, combined with a few deliberately duplicated map implementations per container. This approach forgoes the abstract unification but stays considerably more understandable and maintainable for most teams than a full HKT simulation.
Another alternative is code generation: instead of simulating higher kinded types at compile time, a build step generates a separate, monomorphic implementation from a shared template for every concrete container type. This moves the abstraction out of the type system into the build process and avoids the readability problems of the URI pattern technique, but costs additional build complexity.
8. How fp-ts uses the URI pattern in practice
fp-ts consistently uses the URI pattern shown in the previous sections for its entire type class hierarchy, from Functor through Applicative to Monad. Every data type of the library, such as Option, Either, or Task, registers itself once in the global URItoKind registry and then implements the matching type class instances. Users of the library write generic code against these type classes without having to build their own HKT simulation.
The practical benefit for users is that generic combinators such as pipe, flow, or sequenceT are written once and work for every registered container type without code duplication. Once you understand the mechanism from this article, you can classify fp-ts type errors much faster, because many at first glance cryptic error messages arise directly from the Kind lookup indirection.
9. Direct generics vs. HKT simulation vs. codegen compared
The choice between the three approaches presented depends heavily on team size, functional programming experience, and the actual number of different container types. The following table summarizes the trade-offs.
| Approach | Level of abstraction | Readability | When suitable |
|---|---|---|---|
| Direct, duplicated generics | Low | High | Two to three container types in the project |
| HKT simulation (URI pattern) | High | Low without FP experience | Libraries with many type classes like fp-ts |
| Code generation | Medium | High per generated module | Many nearly identical container types |
| Use the fp-ts library directly | High, ready made | Requires ramp up time | When functional patterns are already team standard |
For most projects outside dedicated functional libraries, building your own HKT simulation is the wrong choice. The practical advice is almost always: either use fp-ts directly as a dependency when the need for generic type classes is real, or rely on the simple, duplicated variant with two or three container types instead of maintaining your own URI pattern infrastructure.
Mironsoft
TypeScript architecture, type system consulting, and refactoring
Functional patterns your team actually understands?
We assess whether an HKT simulation, fp-ts, or a more pragmatic approach fits your project, and build the right type abstraction that your team can maintain long term.
Architecture review
Assessment of whether functional type abstractions justify your project effort
fp-ts onboarding
Gradual integration with training for existing TypeScript teams
Type-level coaching
Workshops on generics, type classes, and advanced type system design
10. Summary
Higher kinded types describe generics over type constructors instead of over concrete types, a feature TypeScript does not natively support. The defunctionalization technique, known as the URI pattern from fp-ts, simulates this missing feature through a global registry that maps string literal URIs to concrete, parameterized types, plus a Kind lookup type that resolves this mapping at compile time.
The simulation enables real, reusable type classes such as Functor or Monad, but has real costs: lower readability without prior functional experience, and separate registries per number of type parameters. For most business applications outside dedicated functional libraries, building your own higher kinded types simulation rarely pays off, either using fp-ts directly or a simpler, deliberately duplicated solution is usually the more pragmatic choice.
Higher Kinded Types Simulation — the essentials at a glance
Core problem
TypeScript does not allow generics over type constructors like Array or Option, only over concrete types themselves.
URI pattern
A global registry maps string literal URIs to concrete, parameterized types. A Kind lookup type resolves this mapping.
Limits
Separate registries per number of type parameters, lower readability without functional experience on the team.
Practical recommendation
Use fp-ts directly when the need is real, otherwise prefer simple duplicated generics over your own HKT infrastructure.