Ergonomic TypeScript Generics with Higher-Order Functions

Much of TypeScript’s flexibility comes from its support for generics. They’re great for building up reusable abstractions so that you can share the “how” across your codebase even as the “what” varies significantly.

In this post, I’ll describe a limitation that recently got in my way, and how I worked around it.

Background

First, a brief refresher on TypeScript generics. Type parameters can describe function parameters and return types:


function headsOrTails<A, B>(a: A, b: B): A | B {
  return Math.random() > 0.5 ? a : b;
}

The caller can use whatever types they wish:


const flip = headsOrTails<string, number>("foo", 5);

Or, instead of providing types explicitly, they can be inferred from the types of the given arguments:


const flip2 = headsOrTails({ foo: "bar" }, new Date());

It’s also possible to use type parameters to describe part of your types:


type Composite<A, B, C> = { bar: B; fn?: (_: A) => C };
function fun<A, B, C>(x: Composite<A, B, C>): B[] {
  return [x.bar];
}

Type argument inference becomes especially valuable when you’re working with composite types like these, saving you from having to specify a bunch of types that don’t concern you:


const r1 = fun<string, number, boolean>({ bar: 5 }); // <--   :|
const r2 = fun({ bar: { x: "asdf" } });              // <--   :)

The Problem

So, I'm writing a thing that I intend to be reused over and over with different inputs, something like this:


type Gnarly<A, B, C, D, E, F, G> = {
  /* complexity here */
};

function thing<A, B, C, D, E, F, G, T>(
  concernBundle: Gnarly<A, B, C, D, E, F, G>,
  f: F,
  fn1: (_: T) => [A, B],
  fn2: (_: C) => T
): (_: T) => number {
  return x => 5;
}

The concernBundle parameter abstracts a bunch of details, but callers only really care about its identity. They want to choose one from an existing set, pass it in, and then fill in the other arguments according to the feature they're building.

I definitely don't want callers to have to specify all of concernBundle's component types (types A-G), but I want to let them specify T, which will likely be a local type in the calling file.

Here's the problem: TypeScript will let you specify all of the type parameters, or none of them, but it doesn't offer a way to specify only some of them.

So what does it look like to call thing()? I'm not even going to write the version that supplies all the type parameters, but here's what happens when you try to infer them:


const result = thing(
  exampleBundle,
  [],
  t => [2 * t.n, { foo: "jkl" }],
  b => ({ n: b ? 5 : Math.random() })
);
// generics.ts:109:15 - error TS2339: Property 'n' does not exist on type 'unknown'.
// 109   t => [2 * t.n, { foo: "jkl" }],

One way to deal with this is to be explicit with type T, not at the function level, but with the argument (MyType below):


type MyType = { n: number };
const what = thing(
  exampleBundle,
  [],
  (t: MyType) => [2 * t.n, { foo: "jkl" }],  // <-- we can specify MyType here to avoid the error above
  b => ({ n: b ? 5 : Math.random() })
);

That's not unreasonable to ask of the caller, but it's a minor speed bump that requires you to stop and think. Can we do better?

My Approach

For a given function call, TypeScript wants us to specify all or none of the type parameters. Fine. Can we split it into two function calls?

Yep!

A higher-order function can take some type parameters and return a function that uses those and takes more:


function doThing<T>() {  // <-- provide this type explicitly
  return function<A, B, C, D, E, F, G>( // <-- infer these
    x: Gnarly<A, B, C, D, E, F, G>,
    f: F,
    fn1: (_: T) => [A, B],
    fn2: (_: C) => T
  ): (_: T) => number {
    return x => 5;
  };
}

Finally, we can take this concept and rearrange it a bit to improve the ergonomics:


function withBundle<A, B, C, D, E, F, G>(x: Gnarly<A, B, C, D, E, F, G>) {
  return {
    doThing: function<T>(
      f: F,
      fn1: (_: T) => [A, B],
      fn2: (_: C) => T
    ): (_: T) => number {
      return x => 5;
    }
  };
}

This yields a tidier call site with more helpful code completion in the editor:


withBundle(exampleBundle).doThing<MyType>(
  [],
  t => [2 * t.n, { foo: "jkl" }],
  b => ({ n: b ? 5 : Math.random() })
);

Conclusion

I hope to one day have a better way of specifying some of a generic function's type parameters, but I'm not holding my breath. For now, this approach has helped me design interfaces that are pleasant to use.

Further reading: