Advanced Typescript: Typing Partially Applicable Functions

YCM Jason - Jun 2 '23 - - Dev Community

This post follows up on How to make functions partially applicable in JavaScript.

Partial Function Application allows functions to collect its arguments in multiple function calls, returning the result once all arguments are collected. For example, f(x, y, z) can be called as f(x)(y, z) or f(x, y)(z) etc.

Some people might confuse this with currying. However, currying is a slightly different concept as it only refers to turning a function with N arguments into N functions that takes in 1 argument. E.g. f(x, y, z) into f(x)(y)(z).

In this article, we will explore how we can define the type PartiallyApplicable<F> that would allow F to be applied partially.

TL; DR

// basically dropping the last element until we have exactly 1 element, this keeps the label of the array
type GetFirstParam<Params extends any[]> = Params extends [any]
  ? Params
  : Params extends [...infer X, any]
  ? GetFirstParam<X>
  : never;

type GetRestParam<Params extends any[]> = Params extends [any, ...infer Rest]
  ? Rest
  : [];

type PrependArg<
  F extends (...args: any[]) => any,
  X extends [any]
> = F extends (...args: infer Args) => infer ReturnType
  ? (...rest: [...X, ...Args]) => ReturnType
  : never;

type PartiallyApplicableUnion<F extends (...payload: any[]) => any> =
  F extends (...args: infer Args) => infer ReturnV
    ? Args extends []
      ? F
      : Args extends [any]
      ? F | (() => PartiallyApplicable<F>)
      :
          | (() => PartiallyApplicable<F>)
          | ((
              ...args: GetFirstParam<Parameters<F>>
            ) => PartiallyApplicable<
              (...argRest: GetRestParam<Parameters<F>>) => ReturnV
            >)
          | PrependArg<
              PartiallyApplicableUnion<
                (...argRest: GetRestParam<Parameters<F>>) => ReturnV
              >,
              GetFirstParam<Parameters<F>>
            >
    : never;

type UnionToIntersection<U> = (
  U extends any ? (arg: U) => any : never
) extends (arg: infer I) => void
  ? I
  : never;

type PartiallyApplicable<F extends (...payload: any[]) => any> =
  UnionToIntersection<PartiallyApplicableUnion<F>>;

// Example:

// notice how the argument names are now preserved
declare const f: PartiallyApplicable<
  (a: number, b: string, c: boolean, d: Date) => string
>;

// all evaluates to string
f(0, 'a', true, new Date());
f(0)('a', true, new Date());
f(0, 'a', true)(new Date());
f()()(3)()('a', true)()()(new Date());
Enter fullscreen mode Exit fullscreen mode

TS Playground

Recap: How to make functions partially applicable in JavaScript

In the previous article, we've explored how we can make any functions partially applicable.

const enablePartialApplication = (fn) => (...args) => {
    if (args.length >= fn.length) return fn(...args);
    return enablePartialApplication(fn.bind(null, ...args));
};

const f = enablePartialApplication((a, b, c) => a + b + c)
f(1)(2, 3) // 6
f(1, 2, 3) // 6
f(1, 2)(3) // 6
f(1)(2)(3) // 6
Enter fullscreen mode Exit fullscreen mode

This function either return the result of fn, if the provided args matches the number of arguments fn accepts, or it returns a partially applicable function that takes in the rest of the arguments.

Goal of this article

The goal of this article is to present a step-by-step approach to tackle type challenges like this. The following sections are progressively more complicated, but my objective is to ensure that anyone can take away something valuable from this article, even if you don't fully comprehend the final conclusion.

Typing the add Function

Function overload is a way to define multiple call signature of a function. For example:

function add(x: number, y: number): number; // first overload
function add(x: number): (y: number) => number; // second overload
function add(x: number, y?: number): number | (y: number) => number {
  if (typeof y === "undefined") return (y: number) => x + y;
  return x + y;
}

add(1)(2); // using the first overload
add(1, 2); // using the second overload
Enter fullscreen mode Exit fullscreen mode

In fact, function overload can be expressed with &. For example:

type Add = ((x: number, y: number) => number) &
  ((x: number) => (y: number) => number);

const add = ((x: number, y?: number): number | ((y: number) => number) => {
  if (typeof y === "undefined") return (y: number) => x + y;
  return x + y;
}) as Add;
Enter fullscreen mode Exit fullscreen mode

Note that in here we need to use as Add since TypeScript wouldn't be able to figure out in which case the function returns a function or a number. In the following article, we will be using the latter syntax as it allows more flexible composition of function overloading.

Typing 2-ary Functions

A 2-ary function, also known as a binary function, is a function that takes exactly two inputs or arguments.

In this section, we'd define PartiallyApplicable2<F>:

type PartiallyApplicable2<F extends (x: any, y: any) => any> = ...
Enter fullscreen mode Exit fullscreen mode

With this, we can simply define Add as PartiallyApplicable2<(x: number, y: number) => number>.

We can tackle this relatively easily. With the help of infer, we can define the type as

type PartiallyApplicable2<F extends (x: any, y: any) => any> = F extends (
  x: infer X,
  y: infer Y
) => infer R
  ? ((x: X, y: Y) => R) & ((x: X) => (y: Y) => R)
  : never;

type Add = PartiallyApplicable2<(x: number, y: number) => number>;
Enter fullscreen mode Exit fullscreen mode

This is relatively simple. Here is a breakdown:

  1. Inferring the argument and return types

     type PartiallyApplicable2<F extends (x: any, y: any) => any> = F extends (
       x: infer X,
       y: infer Y
     ) => infer R
       ? [if-case]
       : [else-case]
    
  2. We know that F must be a function that takes in 2 argument and return something (based on the F extends (x: any, y: any) => any constraint), so we can put never in the [else-case].

     type PartiallyApplicable2<F extends (x: any, y: any) => any> = F extends (
       x: infer X,
       y: infer Y
     ) => infer R 
       ? [if-case]
       : never
    
  3. In the [if-case], we know that there are 2 posible ways to call a 2-ary partially applicable function. You can either call the function with all 2 arguments or 1 after another. So we can simply put ((x: X, y: Y) => R) & ((x: X) => (y: Y) => R):

     type PartiallyApplicable2<F extends (x: any, y: any) => any> = F extends (
       x: infer X,
       y: infer Y
     ) => infer R
       ? ((x: X, y: Y) => R) & ((x: X) => (y: Y) => R)
       : never;
    

With PartiallyApplicable2 we can type any arbitrary 2-ary function easily.

Typing N-ary Function

In computer science, N-ary functions are commonly used to represent operations that take a variable number of arguments.

Typing 2-ary functions is easy. Let's take a step further and think about how we can create a general PartiallyApplicable<F> type that will facilitate any N-ary function.

Before we dive into any code, let's figure out what PartiallyApplicable<F> should do in from a 0-ary to 4-ary functions:

In the following, I would use some psudo-TypeScript to describe function signature. Specifically, (A, B) => R to represent (a: A, b: B) => R. This reduces noise and make things easier to understand.

For illustration purpose, I would ignore the case where you can pass no argument to the partially applicable function, except for the 0-ary function. We will discuss how to deal with this case in a later section.

  • 0-ary function: PartiallyApplicable<() => R>
    • There is only 1 way to call this function:
      1. () => R
  • 1-ary function: PartiallyApplicable<(A) => R>
    • There is only 1 way to call this function:
      1. (A) => R
  • 2-ary function: PartiallyApplicable<(A, B) => R>
    • 2 overloads:
      1. (A, B) => R
      2. (A) => (B) => R
  • 3-ary function: PartiallyApplicable<(A, B, C) => R>
    • 4 overloads:
      1. (A, B, C) => R
      2. (A, B) => (C) => R
      3. (A) => (B, C) => R
      4. (A) => (B) => (C) => R
  • 4-ary function: PartiallyApplicable<(A, B, C, D) => R>
    • 8 overloads:
      1. (A, B, C, D) => R
      2. (A, B, C) => (D) => R
      3. (A, B) => (C, D) => R
      4. (A, B) => (C) => (D) => R
      5. (A) => (B, C, D) => R
      6. (A) => (B, C) => (D) => R
      7. (A) => (B) => (C, D) => R
      8. (A) => (B) => (C) => (D) => R

As you can see, the number of overloads grows exponentially as the number of arguments goes bigger.

Here is an illustration of a 4-ary function:

Consider only the node: remaining: B, C, D and the paths involved.

In fact, this tree that's currently highlighted is exactly PartiallyApplicable<(B, C, D) => R>! It contains 4 "routes" to get to R, namely:

  1. (B) => (C) => (D) => R
  2. (B) => (C, D) => R
  3. (B, C) => (D) => R
  4. (B, C, D) => R

This gives us a clue that (A) => PartiallyApplicable<(B, C, D) => R> would define half of PartiallyApplicable<(A, B, C, D) => R>!

Now let's take a closer look to the left-hand side of the tree:

It actually shares most of the bits as the tree spanning from the node remaining: B, C, D. The only difference being the first call signature:

Notice the symmetry of the lines coming from node remaining: A, B, C, D and the node remaining: B, C, D. The minimum edit to get from the right 3 lines to the left 3 lines is to append (A, ...) to each line!

  1. (B) becomes (A, B)
  2. (B, C) becomes (A, B, C)
  3. (B, C, D) becomes (A, B, C, D)

We will use an imaginary utility type (see latter section on how we define this) called PrependArg<F extends (...args: any[]) => any, X>, that will prepend X to the parameters of F. E.g. PrependArg<(A, B) => R, X> would equal to (X, A, B) => R.

Now, we can get the left-hand side of the tree by simply doing PrependArg<PartiallyApplicable<(B, C, D) => R>, A>. This should yield:

  1. PrependArg<(B) => ..., A> will become (A, B) => ...
  2. PrependArg<(B, C) => ..., A> will become (A, B, C) => ...
  3. PrependArg<(B, C, D) => ..., A> will become (A, B, C, D) => ...

To conclude this section, we have discovered that PartiallyApplicable<(A, B, C, D) => R> is basically:

  1. ((A) => PartiallyApplicable<(B, C, D> => R>) &
  2. PrependArg<PartiallyApplicable<(B, C, D) => R>, A>

In fact, this pattern applies to any N-ary functions, for N > 1. So we can generalise this and say PartiallyApplicable<(A, ...RestArgs) => R> equals to:

((A) => PartiallyApplicable<(...RestArgs> => R>)
    & (PrependArg<PartiallyApplicable<(...RestArgs) => R>, A>)
Enter fullscreen mode Exit fullscreen mode

As with any recursive solution, we'd need a base case. In this case, it would simply be a 0-ary or 1-ary function. In which cases we can simply return the original function signature, as there is only 1 way to call them as discussed previously.

In psudo-TypeScript, the whole solution looks something like:

// not real TypeScript, skipping argument names for simplicity
type PartiallyApplicable<F extends (...any[]) => any> =
  F extends (...infer Args) => infer ReturnV
    ? Args extends [] | [any] // base case
      ? F // there is only 1 way to call it
      : Args extends [infer A, ...infer RestArgs] // recursive case
      ? ((A) => PartiallyApplicable<(...RestArgs) => ReturnV>)
          & PrependArg<PartiallyApplicableUnion<(...RestArgs) => ReturnV>, A>
      : never
    : never;
Enter fullscreen mode Exit fullscreen mode

Implementing in TypeScript

In the previous section, we've come up with a recursive definition of PartiallyApplicable<F> using some psudo-TypeScript.

In this section, we are going to explore how we can implement it in TypeScript.

Let's start with PrependArg. It is quite simple:

type PrependArg<F extends (...args: any[]) => any, X> = F extends (
  ...args: infer A
) => infer R
  ? (x: X, ...args: A) => R
  : never;
Enter fullscreen mode Exit fullscreen mode

This implementation works for union of functions. So PrependArg<() => number | (a: string) => void, boolean> would become ((x: boolean) => number) | (x: boolean, a: string) => void, but not intersection of functions.

In fact, Intersection & is very hard to manipulate in TypeScript, if not impossible. See here for an attempt to do so.

Since union types are much easier to manipulate, we can instead generate a union of all overload functions, then use UnionToIntersection<U>, a magical utility type, to turn A | B into A & B. UnionToIntersection<U> is too "trivial", so I won't dive into the details of it. Instead, I'll leave it as an exercise for you. 🤣 If you are interested, you can read more here.

type UnionToIntersection<U> = (
  U extends any ? (arg: U) => any : never
) extends (arg: infer I) => void
  ? I
  : never;
Enter fullscreen mode Exit fullscreen mode

With this utility, our aim now is to define PartiallyApplicableUnion<F> which generates a union of all overload functions for a partially applicable F. We have roughly defined this using psudo-TypeScript previously, so let's translate that into TypeScript and have everything combined.

type PrependArg<F extends (...args: any[]) => any, X> = F extends (
  ...args: infer A
) => infer R
  ? (x: X, ...args: A) => R
  : never;

type PartiallyApplicableUnion<F extends (...payload: any[]) => any> =
  F extends (...args: infer Args) => infer ReturnV
    ? Args extends [] | [any]
      ? F
      : Args extends [infer Arg0, ...infer ArgRest]
      ?
          | ((
              arg0: Arg0
            ) => PartiallyApplicable<(...argRest: ArgRest) => ReturnV>)
          | PrependArg<
              PartiallyApplicableUnion<(...argRest: ArgRest) => ReturnV>,
              Arg0
            >
      : never
    : never;
Enter fullscreen mode Exit fullscreen mode

Now all we have to do is:

type UnionToIntersection<U> = (
  U extends any ? (arg: U) => any : never
) extends (arg: infer I) => void
  ? I
  : never;

type PartiallyApplicable<F extends (...payload: any[]) => any> =
  UnionToIntersection<PartiallyApplicableUnion<F>>;

/* Correctly yields 4 call paths to get to the result: 
 * ((x: number, x: string, c: boolean) => number) 
 *     & ((x: number, arg0: string) => (c: boolean) => number)
 *     & ((arg0: number) => (
 *         (x: string, c: boolean) => number)
 *             & ((arg0: string) => (c: boolean) => number)
 *     )
 */
type F = PartiallyApplicable<(a: number, b: string, c: boolean) => number>
Enter fullscreen mode Exit fullscreen mode

You can see the working code here.

Calling the Partially Applicable Function with No Arguments

To extend our implementation to support calls with no arguments, it's pretty simple. Our call tree for a 4-ary partially applicable function will become:

Basically calling the partially applicable function with ( ) should just simply return the same function. So we can simply add one more case to our recursive case and add support for 1-ary functions:

type PartiallyApplicableUnion<F extends (...payload: any[]) => any> =
  F extends (...args: infer Args) => infer ReturnV
    ? Args extends []
      ? F
      : Args extends [any]
      ? F | (() => PartiallyApplicable<F>) // allow calls with no arguments
      : Args extends [infer Arg0, ...infer ArgRest]
      ?
          | (() => PartiallyApplicable<F>) // allow calls with no arguments
          | ((
              arg0: Arg0
            ) => PartiallyApplicable<(...argRest: ArgRest) => ReturnV>)
          | PrependArg<
              PartiallyApplicableUnion<(...argRest: ArgRest) => ReturnV>,
              Arg0
            >
      : never
    : never;
Enter fullscreen mode Exit fullscreen mode

Note that an 1-ary partially applicable function will now have 2 ways to call.

You can see the working version here.

Extended: Preserving argument names

Our current implementation works totally fine. But there is one thing that keeps it away from being perfect. That is, the argument names are not kept in the overload functions.

This is due to the way we infer our arguments. When we do F extends (x: infer X) => any, we would lose the argument name for X.

In TypeScript, argument names are basically stored in the label of array. So when you do Parameters<(a: number, b: number) => any>, you will get [a: number, b: number]. However, these label can't be easily retrieved.

Instead of retreiving the labels, what we could do is to avoid using infer, but keep track of the list at all times. Instead of [infer X, ...infer Rest], we would somehow retrieve the X as a list. We will also edit PrependArg<F, X> to take in X as an array too, so the label is passed down. Here is a way to do it:

// basically dropping the last element until we have exactly 1 element, this keeps the label of the array
type GetFirstParam<Params extends any[]> = Params extends [any]
  ? Params
  : Params extends [...infer X, any]
  ? GetFirstParam<X>
  : never;

type GetRestParam<Params extends any[]> = Params extends [any, ...infer Rest]
  ? Rest
  : [];

type PrependArg<
  F extends (...args: any[]) => any,
  X extends [any] // expect an array so the label is preserved
> = F extends (...args: infer Args) => infer ReturnType
  ? (...rest: [...X, ...Args]) => ReturnType
  : never;

type PartiallyApplicableUnion<F extends (...payload: any[]) => any> =
  F extends (...args: infer Args) => infer ReturnV
    ? Args extends []
      ? F
      : Args extends [any]
      ? F | (() => PartiallyApplicable<F>)
      :
          | (() => PartiallyApplicable<F>)
          | ((
              ...args: GetFirstParam<Parameters<F>>
            ) => PartiallyApplicable<
              (...argRest: GetRestParam<Parameters<F>>) => ReturnV
            >)
          | PrependArg<
              PartiallyApplicableUnion<
                (...argRest: GetRestParam<Parameters<F>>) => ReturnV
              >,
              GetFirstParam<Parameters<F>>
            >
    : never;

type UnionToIntersection<U> = (
  U extends any ? (arg: U) => any : never
) extends (arg: infer I) => void
  ? I
  : never;

type PartiallyApplicable<F extends (...payload: any[]) => any> =
  UnionToIntersection<PartiallyApplicableUnion<F>>;

// Example:

// notice how the argument names are now preserved
declare const f: PartiallyApplicable<
  (a: number, b: string, c: boolean, d: Date) => string
>;

// all evaluates to string
f(0, 'a', true, new Date());
f(0)('a', true, new Date());
f(0, 'a', true)(new Date());
f()()(3)()('a', true)()()(new Date());
Enter fullscreen mode Exit fullscreen mode

You can find the fully working version here.

Limitations

This implementation has a few limitations and will not work for the following cases:

  • Functions with optional arguments
  • Functions with generics
  • Functions with variable argument length

Conclusion

In this article we have looked at how we can type partially applicable functions in TypeScript. We went through the patterns of partially applicable N-ary functions and spotted the recursive structure within it.

You might be wondering, why did I spend the time trying to solve this? Partial function application isn't that useful anyways. While it is true, I started looking into typing partially applicable functions because all functions in my favourite utility library Ramda are partially applicable.

However, in the type definitions, these partially applicable functions are explicitly typed with overflows (example). So the types end up being quite noisy and complicated over time. So I decided to take on this challenge of typing any N-ary functions to be partially applicable.

I hope you enjoyed reading this article. And if you have made it all the way here, congratulations and thank you for your time. I hope you've taken away somethings useful from this article. Please feel free to share your thoughts, or suggest any improvements you can think of. I'd be excited to hear from you.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player