5 minFunctional Programming, with Types, part 2

Curried Functions in TypeScript

“Use currying to create reusable functions while keeping TypeScript types intact.”

Currying plays an important role in the functional programmer’s toolkit. In this post, we’ll look at what currying is, why it’s worth reaching for, and how TypeScript helps us keep our types intact along the way. By the end, we’ll answer the question: how do we preserve types when we choose a transformation now and provide its data later?

Everything everywhere all at once

In Generics in TypeScript, I discussed how to use a type parameter to establish a relationship between a function’s input and its output. In this post, we’ll see how to establish the same relationship on generalized functions that haven’t received the data they’re operating on.

Before getting into that though, let’s take a look at our starting point - mapValues which is essentially a wrapper for JavaScript’s Array.prototype.map() method. Running it logs an object whose lengths property is a number[]:

Uncurried mapValuesTypeScript 5.9.3 · strict
const array = ["TypeScript", "currying", "applesauce"];

const mapValues = <A, B>(
  transform: (value: A) => B,
  values: readonly A[],
): B[] => values.map(transform);

const lengths = mapValues((value: string) => value.length, array);

console.log({ lengths });

If we dissect the type annotations, we see that:

  • transform is a function that operates on elements of type A and produces elements of type B.
  • values is a readonly array of As.
  • the return value of our function is an array of Bs.

While it can be beneficial to wrap APIs like .map() inside of our own functions, we can really level up this code by transforming mapValues into its curried version.

Currying turns a function that accepts multiple parameters into a sequence of functions that have an arity of one.

To simplify that a bit: arity is the term for describing the number of arguments a function will receive. Rather than trying to memorize a definition though, let’s look at curried and uncurried versions side by side.

multiply_ (the trailing underscore marks the curried version) takes its arguments independently, whereas multiply takes both at call time.

const multiply = (x: number, y: number) => x * y;
const multiply_ = (x: number) => (y: number) => x * y;

By transforming multiply to its curried version, we can fix a value as the first argument, then reuse the result throughout our code to double or triple numbers. Note: the act of fixing an argument onto a function is known as partial application.

const double = multiply_(2);
const triple = multiply_(3);

double(5); // 10
triple(5); // 15

In essence, currying allows us to abstract away shared logic and promote code reuse. Pretty simple, right? Perhaps. But, as programmers, we know to never underestimate the power of simplicity!

Abstracting shared logic

Abstraction is the process of decoupling code from the context in which it operates. It’s through abstraction that we can begin to make code more portable. It’s a key part of functional programming, as it enables developers to share code throughout programs and even across projects.

On that note, let’s revisit mapValues. It has an arity of 2, so it needs both of these arguments in order to execute:

  1. transform is the decision we make beforehand - e.g. “get the length”, “add a label”, “split the string”, etc.
  2. values is the data. It isn’t static. It can change every time the function is invoked.

If we split these into separate calls, it doesn’t change the <A, B> relationship we described earlier. transform still fixes A and B, and values still has to be an array of As. The only difference is when each one arrives.

Taking the data last is really powerful, but more on that later. For now, we’ll create getLengths - a new function for getting the length of a string - by supplying only the transform argument:

Curried mapValuesTypeScript 5.9.3 · strict
const array = ["TypeScript", "currying", "applesauce"];

const mapValues =
  <A, B>(transform: (value: A) => B) =>
  (values: readonly A[]): B[] =>
    values.map(transform);

// remove `: string` below and TypeScript can no longer tell what type `value` is
const getLengths = mapValues((value: string) => value.length);

console.log({ lengths: getLengths(array) });

And just like that, we’ve rewritten mapValues to be a fountain from which other functions can be brought to life. We’re in a much better position for promoting code reuse throughout our systems.

Equally important, we’ve preserved the type relationship! In this version, getLengths is a function with the type (values: readonly string[]) => number[].

TypeScript infers A and B from the transformation in the first call. Here, the callback accepts a string and returns a number, so the function returned by mapValues already knows which arrays it accepts.

Notice the : string on the callback we pass to mapValues. Remove it and the compiler tells you 'value' is of type 'unknown'. In the uncurried version we could skip the annotation, because TypeScript could infer A from the array. In the curried version, the array hasn’t arrived yet, so an inline callback like this one has nothing to infer A from. It needs the annotation, or it needs to be a function that’s already typed.

Reduce reuse recycle

We created getLengths from mapValues, but that’s just the start of things to come. Suppose we have an array of post titles. getLengths gives us their character counts, while addLabels turns each title into a { label, value } option for a dropdown.

Reuse typed transformationsTypeScript 5.9.3 · strict
const titles = ["Generics in TypeScript", "Typing curried functions"];
const numbers = [2, 4, 6, 8, 10];

const mapValues =
  <A, B>(transform: (value: A) => B) =>
  (values: readonly A[]): B[] =>
    values.map(transform);

const squareNumbers = mapValues((value: number) => value * value);
const getLengths = mapValues((value: string) => value.length);
const addLabels = mapValues((title: string) => ({
  label: title,
  value: title,
}));

console.log({ lengths: getLengths(titles) });
console.log({ options: addLabels(titles) });
console.log({ squares: squareNumbers(numbers) });

// Uncomment to see TypeScript reject the wrong input type:
// getLengths(numbers);

The code block demonstrates that the input doesn’t have to be an array of strings, either. squareNumbers takes numbers and returns numbers. Whatever the transformation, they all come from the same mapValues.

Observing the typings, we see that:

  • in addLabels, TypeScript infers A as string and B as { label: string; value: string }.
  • in squareNumbers, TypeScript infers number for both A and B.

It’s really helpful to see this type inference appear on hover, but the true benefit lies in how TypeScript enforces these contracts.

Uncomment the last line in the code block above to pass numbers to getLengths. TypeScript rejects it immediately:

Argument of type 'number[]' is not assignable to parameter of type 'readonly string[]'.
  Type 'number' is not assignable to type 'string'.

And rightfully so! The types don’t match up.

Practical currying: validation check

Say a post editor allows 60 characters in a title and 160 in a summary. Both fields need the same length check with a different limit. With maxLength, we can choose the limit when we set up the form and check the text when someone types into it.

Configure a length checkTypeScript 5.9.3 · strict
const maxLength = (limit: number) => (value: string): boolean =>
  value.length <= limit;

const fitsTitle = maxLength(60);
const fitsSummary = maxLength(160);

const text = "How to build a TypeScript validator for every input in your application";

console.log({ title: fitsTitle(text), summary: fitsSummary(text) });
// { title: false, summary: true }

const summary = "x".repeat(200);
console.log({ longSummary: fitsSummary(summary) });
// false. Change 160 to 240 above and run again

maxLength(60) returns a (value: string) => boolean. The returned function remembers the limit, so we can call fitsTitle with new text each time the input changes. The same text can pass one field’s check and fail the other’s.

Now, let’s pretend we just got word that summaries can be 240 characters. Change 160 to 240 in fitsSummary and run it again. The 200-character summary now passes, and no caller had to change, because none of them pass the limit themselves.

Right now fitsSummary only answers yes or no. Pair it with a message like “Your summary is too long” and the form has a reusable rule that can explain what failed.

Recap

And thus concludes our TypeScript tour. We preserve types by putting the generics on the first call: mapValues infers A and B from the transform we choose now, so the function it returns already knows which arrays it accepts later. Along the way, we learned that currying and partial application go hand-in-hand. (Remember, each time we’ve fixed transform as the first argument of mapValues, we’ve partially applied that function.)

Thomas Greco writes about software engineering, life, and what he’s learning and building.