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][issue_1] my [breath][issue_2]. For now, this approach has helped me design interfaces that are pleasant to use.

Further reading:

- [TypeScript Generics][typescript generics]
- [Generic higher-order functions in TypeScript][spin_bacon]
[TypeScript generics]: https://www.typescriptlang.org/docs/handbook/generics.html
[spin_bacon]: https:/typescript-higher-order-functions/

[issue_1]: https://github.com/microsoft/TypeScript/issues/10571
[issue_2]: https://github.com/Microsoft/TypeScript/issues/16597