What stands in our way, is when you try to pass a type variable F
as type parameter to another type variable T
, like T<F>
, TS just doesn't allow that even if you know T
is in fact a generic interface.
There's a discussion on this topic dated back to 2014 in a github issue, and it's still open, so TS team probably won't support it in near future.
The term for this language feature is called higher kinded type. Using that search keyword, google took me to a trip down the rabbit hole.
It turns out theres exist a very clever workaround!
By leveraging TS declaration merging (aka module augmentation) feature, we can effectively define an empty "type store" interface, which acts like a plain object that holds reference to other useful types. Using this technique, we are able to overcome this blocker!
I'll use your case as example to cover the idea of this technique. If you want to dive deeper, I include some useful links at the end.
Here's the TS Playground link (spoiler alert) to the final result. See it in live for sure. Now let's break it down (or should I say build it up?) step by step.
- First, let's declare an empty
TypeStore
interface, we'll update it's content later.
// just think of it as a plain object
interface TypeStore<A> { } // why '<A>'? see below
// example of "declaration merging"
// it's not re-declaring the same interface
// but just adding new members to the interface
// so we can amend-update the interface dynamically
interface TypeStore<A> {
Foo: Whatever<A>;
Maybe: Maybe<A>;
}
- Let's also get the
keyof TypeStore
. Noted that as content of TypeStore
gets updated, $keys
also get updated accordingly.
type $keys = keyof TypeStore<any>
- Now we patch up the missing language feature "higher kinded type", using a utility type.
// the '$' generic param is not just `string` but `string literal`
// think of it as a unique symbol
type HKT<$ extends $keys, A> = TypeStore<A>[$]
// where we mean `Maybe<A>`
// we can instead use:
HKT<'Maybe', A> // again, 'Maybe' is not string type, it's string literal
- Now we have the right tools, let's start building useful stuffs.
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));
}
}
// HERE's the key!
// You put the freshly declare class back into `TypeStore`
// and give it a string literal key 'Maybe'
interface TypeStore<A> {
Maybe: Maybe<A>
}
- Finally
FMap
:
// `infer $` is the key here
// remember what blocked us?
// we cannot "infer Maybe from T" then apply "Maybe<A>"
// but we can "infer $" then apply "HKT<$, A>"!
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);
Reference
- The github discussion on supporting higer kinded type in TS
- Entrance to the rabbit hole
- Declaration Merging in TS Handbook
- SO post on higher kinded type
- Medium post by @gcanti, on higher kinded types in TS
fp-ts
lib by @gcanti
hkts
lib by @pelotom
typeprops
lib by @SimonMeskens