When attempting to pass a type variable F
as a type parameter to another type variable T
, such as T<F>
, TypeScript (TS) prohibits this even when it is known that T
is a generic interface.
A lingering discussion from 2014 on this subject in a GitHub issue remains unresolved, suggesting that the TS team may not provide support for it anytime soon.
This language feature is referred to as higher kinded type. A deep dive into this topic using Google led to interesting findings.
An ingenious workaround has been discovered!
By making use of TS's declaration merging (also known as module augmentation) feature, an empty "type store" interface can be defined effectively. This acts as a simple object holding references to other valuable types, enabling one to bypass this limitation!
Your specific case serves as an example to illustrate the concept behind this technique. For those interested in delving deeper, additional helpful links have been included at the end.
Here's the TS Playground link (spoiler alert) showcasing the final result. To fully grasp the concept, explore it live. Now let's dissect (or rather construct!) it step by step.
- To begin, define an empty
TypeStore
interface, which will be updated with content later on.
// treat it as a basic object
interface TypeStore<A> { } // why '<A>'? explained below
// exemplifying "declaration merging"
// instead of re-declaring the same interface,
// new members are added to dynamically update the interface
interface TypeStore<A> {
Foo: Whatever<A>;
Maybe: Maybe<A>;
}
- Obtain the
keyof TypeStore
as well. Note that as the contents of TypeStore
evolve, $keys
reflects these changes accordingly.
type $keys = keyof TypeStore<any>
- Incorporate a utility type to address the missing language feature of "higher kinded type."
// the '$' generic param signifies a unique symbol, not just 'string'
type HKT<$ extends $keys, A> = TypeStore<A>[$]
// instead of directly meaning `Maybe<A>`
// the syntax becomes:
HKT<'Maybe', A> // once more, 'Maybe' isn't of string type but is a string literal
- Now equipped with the necessary tools, proceed to develop useful components.
interface Functor<$ extends $keys, A> {
map<B>(f: (a: A) => B): HKT<$, B>
}
class Maybe<A> implements Functor<'Maybe', A> {
constructor(private readonly a: A) {}
map<B>(f: (a: A) => B): HKT<'Maybe', B> {
return new Maybe(f(this.a));
}
}
// The crucial step!
// Introduce the newly declared class back into `TypeStore`
// providing it with a string literal key 'Maybe'
interface TypeStore<A> {
Maybe: Maybe<A>
}
- Lastly, conclude with
FMap
:
// pivotal use of `infer $` here
// recall the initial obstacle faced?
// while direct inference of "Maybe from T and applying Maybe<A>" was impossible,
// inferring `$` and implementing "HKT<$, A>" became viable!
interface FMap {
<A, B, FA extends { map: Function }>
(f: (a: A) => B, fa: FA): FA extends HKT<infer $, A> ? HKT<$, B> : any
}
const map: FMap = (fn, Fa) => Fa.map(fn);
References
- The ongoing conversation regarding higher kinded type adoption in TS on GitHub
- Entrance to a comprehensive exploration of higher kinded type in TypeScript
- Declaration Merging detailed in the TS Handbook
- Stack Overflow thread elucidating higher kinded type
- Insightful Medium post by @gcanti about higher kinded types in TS
fp-ts
library developed by @gcanti
hkts
library created by @pelotom
typeprops
library by @SimonMeskens