Back

Forever Functional: Complex Typing in TypeScript, Part 2

Forever Functional: Complex Typing in TypeScript, Part 2

Adding types to some functional programming constructs may not be simple, and this article will show you different ways of providing data typing for currying functions.

In the previous article we saw how to work out a TypeScript version of our partial application code. This proved to be feasible but a bit tricky! In this second part, let’s turn our attention to another functional programming tool, currying, and see how to develop a fully typed TypeScript version.

Just to help you remember, currying transforms a function with several parameters into a new function that receives the first argument and returns a function that receives the second argument, which in turn returns a final function accepting the last, third argument and producing the result. (For the reasons and advantages of doing this, refer to the previous article.) The following image from my Mastering JavaScript Functional Programming book, shows the concept.

image

For an example (taken from the book) we could have:

const make3 = (a: string, b: number, c: string): string => `${a}:${b}:${c}`;

const f1 = curry(make3);
// (arg: string) => (arg: number) => (arg: string) => string

const f2 = f1("A");
// (arg: number) => (arg: string) => string

const f3 = f2(2);
// (arg: string) => string

const f4 = f3("Z");
// string -- f4 equals "A:2:Z"

The JavaScript code to implement a currying higher-order function was as follows:

const curry = (fn) =>
  fn.length === 0 ? fn() : (p) => curry(fn.bind(null, p));

The function is recursive, and that’s a challenge for typing. Let’s see how to do that, but first we’ll go with a simplified version.

Checking types - the simple version

If we knew that we’d never curry functions with, say, more than four parameters, we could make do with a simple, though longish solution.

type SimpleCurry<P extends any[], R> = 0 extends P["length"]
  ? R
  : EQUALS<1, P["length"]> extends true
  ? (a: P[0]) => SimpleCurry<[], R>
  : EQUALS<2, P["length"]> extends true
  ? (a: P[0]) => SimpleCurry<[P[1]], R>
  : EQUALS<3, P["length"]> extends true
  ? (a: P[0]) => SimpleCurry<[P[1], P[2]], R>
  : EQUALS<4, P["length"]> extends true
  ? (a: P[0]) => SimpleCurry<[P[1], P[2], P[3]], R>
  : never;

In this type definition, P represents the array of types for the parameters of the function we are currying, and R represents the result type for that function.

  • When the function has no parameters, we return R. This is correct because in our curry() function, if fn.length is zero, we call fn() and return the produced value.
  • if the function has one parameter, we return a curried function with no parameters
  • if the function has two parameters, we return a curried function with just one parameter.
  • if the function has three parameters, we return a curried function with two parameters.
  • if the function has four parameters, we return a curried function with three parameters.
  • if the function has five or more parameters, we return never indicating an error.

This is fairly straightforward, and we can now write the following, which works perfectly well. Note I changed from an arrow function to a classic definition; we’ll see why, below.

function curry<P extends any[], R>(
  fn: (...args: P) => R
): SimpleCurry<P, R>;

function curry(
  fn: (...args: any[]) => any
): SimpleCurry<Parameters<typeof fn>, ReturnType<typeof fn>> {
  return fn.length === 0
    ? fn()
    : (x: any) => curry(fn.bind(null, x));
}

You may wonder why we have two definitions for the function. TypeScript cannot verify that our curry() function works correctly because it cannot deduce that, for every function, it will eventually produce a result instead of another curried function. We used a trick in the previous article, which serves well here. We are defining an overloaded function, but with just one signature, which loosens things up for TypeScript. (By the way, it is possible to define overloading for arrow functions, but I think it’s clearer how I did it.) Obviously, this isn’t a very safe way of working; you have to ensure on your own that the function is type-correct because this trick essentially bypasses some of TypeScript’s checks!

So, our limited version works well, but let’s aim for a version that works with any number of parameters.

Checking types — the full version

Since the currying function is recursive, we must work recursively. There are two cases that we must consider:

  • if we curry an unary function (that is, with a single parameter) the result will be the function itself

  • if we carry a function with two or more parameters, the output will be a unary function (with the first parameter) that will return a (curried) function that will deal with the rest of the parameters.

We can write this the following way:

type FullCurry<P, R> = P extends []
  ? R
  : P extends [infer H]
  ? (arg: H) => R // only 1 arg
  : P extends [infer H, ...infer T] // 2 or more args
  ? (arg: H) => FullCurry<[...T], R>
  : never;

Let’s analyze the code. Again, P is the array of function parameters types, and R is its result type.

  • with no parameters, the type is R
  • with a single parameter of type H, the result of currying is a function that gets an H-type argument and produces an R-type result
  • with more parameters (H and the rest, which we collectively call T — “H” and “T” stand for “Head” and “Tail”, as usual when working with lists) the result of currying is a function that gets an H-type argument and produces a curried function with T-type parameters that eventually returns an R-type result

The full TypeScript code is essentially the same, only using the new FullCurry type instead.

function curry<P extends any[], R>(
  fn: (...args: P) => R
): FullCurry<P, R>;

function curry(fn: (...args: any) => any) {
  return fn.length === 0
    ? fn()
    : (x: any) => curry(fn.bind(null, x));
}

We’ve done it! This recursive type definition can deal with any number of parameters, so we have a general solution. However, remember a restriction: currying only works with functions that have a known, fixed number of parameters, and our code won’t work with other functions.

Summary

We’ve shown how complex typing also applies to currying, and we have to use an overloading trick to achieve this. Currying is an essential functional programming technique, and in this article, we’ve seen how to use it with TypeScript for better, safer coding; it’s a win!

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay