Generics as Type Params in Typescript

2024-09-04

#typescript #fp 

About a year ago, I wrote a post about an “Option” type in Typescript. In this post, I would like to revisit that and make a case for a new Typescript language feature: generics as type parameters (a.k.a higher-kinded types a.k.a. type classes).

Before we get into the proposal, I’d like to outline two types that will help me make my case: Identity and Maybe. Before that, let’s talk about a math-y term: functor.

What is a functor?

As much as I would love to write about it, this post isn’t about category theory (I promise). That said, we will be mentioning the word “functor” (not to be confused with function). A functor must obey some mathematical laws and has implications in functional composition, but for our purposes, we only need to know one thing:

A functor is a container which can be mapped

For this post, let’s just focus on that. If an object has a map function, then it’s a functor. We’ve already got one of those provided to us in JavaScript: Array!

const strLen = (x: string) => x.length;

const fruit = Array.of("apple", "banana", "watermelon");
// type: string[]
// value: ["apple", banana", "watermelon"]

const fruitLen = Array.of("apple", "banana", "watermelon").map(strLen);
// type: number[]
// value: [5, 6, 10]

I am using Array.of here for illustrative purposes; it’s the same as defining the array with = [...].

The Identity functor

In my last post on this subject, I made a Container type using pure functions. This time, I’m going to use a generic class and call it Identity. Here’s the definition in full:

class Identity<T> {
  _value: T;

  constructor(x: T) {
    this._value = x;
  }

  static of<A>(x: A): Identity<A> {
    return new Identity(x);
  }

  map<B>(fn: (a: T) => B): Identity<B> {
    return Identity.of(fn(this._value));
  }
}

This doesn’t really do much. It just holds a value (this._value) and let’s consumers run functions on it (map). The type of this._value is a generic T - a container can hold any type. Here it is in action:

const taco = Identity.of("taco");
// type: Identity<string>
// value: { _value: "taco" }

const tacoLen = Identity.of("taco").map(strLen);
// type: Identity<number>
// value: { _value: 4 }

Notice that we can call .map on an Identity and it will update the type and run the function on the value stored within it. That’s all it does…just holds stuff and runs functions on the stuff.

The Maybe functor

The post I wrote last year focused on building up an Option type. I worry that the implementation got a little complicated with pure functions, so I’m going to also use a class for this example as well. We’re calling it Maybe this time around. Here it is in full:

class Maybe<T> {
  _value: T;

  constructor(x: T) {
    this._value = x;
  }

  static of<A>(x: A): Maybe<A> {
    return new Maybe(x);
  }

  map<B>(fn: (a: T) => B): Maybe<B> {
    return this.isNothing() ? this : Maybe.of(fn(this._value));
  }

  isNothing() {
    return this._value == null || this._value == undefined;
  }
}

This is almost the same as Identity. The difference is that we check if the value is null or undefined and, if it is, we don’t run fn on the value. This helps us avoid error-handling in every function. Let’s see it in action:

const maybeRealLen = Maybe.of("Narwhal").map(strLen);
// type: Maybe<number>
// value: { _value: 7 }

const maybeNotRealLen = Maybe.of(null).map(strLen);
// type: Maybe<null>
// value: { _value: null }

Notice how we are able to call .map(strLen) here and it doesn’t blow up when the value is null. We also didn’t need to adjust our underlying strLen function at all. That’s the power behind Maybe.

There is another cool thing happening here: we’re using the same strLen function in all these examples. Regardless of which functor we’re using, if the generic type (T) is the same (string) then we can use the same function in map.

Side note: Typescript isn’t 100% happy with this definition of Maybe, but I wanted to keep the typing complexity down.

Similarities

So we have three functors: Array, Identity, and Maybe. One holds a set of values, one holds a single value, and one might hold a value. Since they’re all functors, they all have a map method. They look very similar:

const fruitLen = Array.of("apple", "banana", "watermelon").map(strLen);
const tacoLen = Identity.of("taco").map(strLen);
const maybeRealLen = Maybe.of("Narwhal").map(strLen);

Removing what’s different will help us see the pattern here:

<Functor>.of(<stuff>).map(<function>);

And if you look closely at the map method in each class…the method signature is basically the same:

class Functor<T> {
  // ...

  map<B>(fn: (a: T) => B): Funcor<B> {
    // ...
  }
}

Just swap out Functor for Identity or Maybe (the built-in Array works fairly similarly as well).

This is a very valuable property because we can write code that only works with container types (functors) and doesn’t need to care if it’s a Maybe or some other type of functor (e.g. a Task functor that runs an async operation). I know I’m kinda hand-waving here, but I promised you earlier I wouldn’t get into category theory. If you want to read more, check out this series of posts.

Defining a Functor interface

Let’s say we want to make a new functor (e.g. Either, IO, or Task). Since we know that a functor must implement a map method, we should be able to write an interface for it, right? That would certainly make it easy for us to ensure we’ve created it correctly. Here’s how we could try to implement that:

// define an inteface for functors
interface Functor<F> {
  map: <A, B>(fn: (a: A) => B) => (fa: F<A>) => F<B>;
}

// make some functors with that interface

const ArrayFunctor: Functor<Array> {
  map: /* ... */
}

const IdentityFunctor: Functor<Identity> {
  map: /* ... */
}

const MaybeFunctor: Functor<Maybe> {
  map: /* ... */
}

However, this does not work!

interface Functor<F> {
  map: <A, B>(fn: (a: A) => B) => (fa: F<A>) => F<B>;
                                       ----     ---- Type 'F' is not generic.
}

In this naive attempt, we’re supplying a generic F that we’re trying to use as a generic type itself (F<A>). This is not something that Typescript supports.

Proposal(s)

One of the oldest proposals I’ve found for this was filed in the Typescript repository almost a decade ago (issue). In that proposal, the functor interface would be defined like so:

interface Functor<F<~>> {
  map: <A, B>(fn: (a: A) => B) => (fa: F<A>) => F<B>;
}

In this proposal (which I personally like) we use F<~> to specify that the generic F type takes in a single parameter. A generic generic, if you will. This could be expanded to multiple parameters (e.g. T<~,~> to represent generic types that take in two arguments like Record or Pick).

Haskell (one of the languages that does support this) calls these “type parameters”.

Workarounds

This might seem academic and outside of the realm of practical application, but the sheer number of duplicate issues in the Typescript repository and 3rd-party workarounds should hopefully convince you otherwise. One of the more popular solutions is to pull in fp-ts, which requires a lot of boilerplate to achieve the desired effect:

import { HKT, URIS, Kind } from 'fp-ts/HKT';

type Identity<T> = {};
type Maybe<T> = {};

// merge the "URItoKind" interface from fp-ts with our own stuff
declare module 'fp-ts/HKT' {
  interface URItoKind<T> {
    'Identity': Identity<T>
    'Maybe': Maybe<T>
  }
}

interface Functor<F> {
  URI: F;
  map: <A, B>(fn: (a: A) => B) => (fa: HKT<F, A>) => HKT<F, B>;
}

interface Functor1<F extends URIS> {
  URI: F;
  map: <A, B>(fn: (a: A) => B) => (fa: Kind<F, A>) => Kind<F, B>;
}

// define Functor2 for functors with two type parameters, etc.

const maybeFunctor: Functor1<'Maybe'> = {
  URI: 'Maybe',
  map: /* ... */
}

That’s…a lot of code to get around the problem at hand. Check out this video (the whole series is great) for an explanation of this approach.

Conclusion

Typescript is a huge improvement over JavaScript, but there are a few powerful features that it simply does not support (yet). I can see how, to the uninitiated, this sort of thing might seem unnecessary or only useful to hard-core functional programmers. However, the same was said of lambda functions or filter, map, and reduce before they made it into the mainstream zeitgeist…now they’re standard in almost every language.