DEV Community

Cover image for Forever Functional: Complex Typing in TypeScript
OpenReplay Tech Blog
OpenReplay Tech Blog

Posted on • Originally published at blog.openreplay.com

Forever Functional: Complex Typing in TypeScript

by Federico Kereki

Data typing is usually simple, but with functional techniques, recursion, and more, it can become complex. This article will complement the previous one on partial application, by developing full typing in TypeScript.

In the previous article in this series, we learned how to do partial application in JavaScript, and developed a higher-order partial() function... but what about a TypeScript version?

It happens that typing our function is not trivial, and requires several interesting techniques, so in this article we'll complete the work from the previous article, going from JavaScript to TypeScript, and adding full type controls. (For more detail, you can check out my Mastering JavaScript Functional Programming book, in which I also discuss currying and other transformations.) You may want to look at our code again, to have it fresh in mind.

Check matching types

When analyzing the provided and missing parameters, we must check that types agree among them, one by one. However, when defining types we cannot just write an if or a loop, so we'll have to find a workaround: our declaration will use ternary operators and recursion in place of ifs and loops.

We will write a TypesMatch<P,A> type declaration (P stands for "parameters", and A for "arguments"; both will be tuples with types) that will either produce boolean (if P and A matched) or never (if there was a mismatch). The declaration is as follows:

type TypesMatch<
  P extends any[],
  A extends any[]
> = 0 extends P["length"]                      
  ? boolean                      1️⃣
  : 0 extends A["length"]        
  ? boolean                      2️⃣
  : [P, A] extends [         
      [infer PH, ...infer PT],
      [infer AH, ...infer AT]?
    ]
  ? AH extends undefined       
    ? TypesMatch<PT, AT>         3️⃣
    : PH extends AH             
    ? TypesMatch<PT, AT>         4️⃣
    : never
  : never;
Enter fullscreen mode Exit fullscreen mode

The 0 extends P["length"] line 1️⃣ may confuse you, but it's the way of checking if the length of P is zero. We use infer and spreading to separate the first types of P and A (PH and AH; H is for "head") from the rest (PT and AT; T is for "tail").

Our type is as follows:

  • if either P 1️⃣ or A 2️⃣ is empty, return boolean
  • if the first type in A is undefined 3️⃣ or if the first type in A matches the first type in P 4️⃣ discard the first type in P, discard the first type in A, and recursively analyze the remaining types
  • otherwise (if the first type in A is not undefined, but doesn't match the first type in P) return never

We can do some quick tests to verify everything is in order -- the "ok" types are boolean ( meaning they are correct) and the "bad" types are never (so they are wrong):

type ok1 = TypesMatch<
  [boolean, number, string],
  [undefined, undefined, undefined]
>;
type ok2 = TypesMatch<
  [boolean, number | string, string],
  [boolean, undefined, string]
>;
type ok3 = TypesMatch<
  [boolean, number | string, string],
  [boolean, string, string]
>;

type bad1 = TypesMatch<
  [boolean, number, string],
  [undefined, string, number]
>;
type bad2 = TypesMatch<
  [boolean, number | string, string],
  [string, undefined, string]
>;
type bad3 = TypesMatch<
  [boolean, number | string, string],
  [boolean, boolean, string]
>;
Enter fullscreen mode Exit fullscreen mode

Deduce pending parameters

We'll need another auxiliary type, Partialize<P,A>, that will receive a tuple with the parameter types and another with the argument types, and produce a tuple with the types for the still not provided arguments; i.e., those types in P for which there's a corresponding undefined type in A. Let's assume we already checked that types match (as we saw in the previous section). We can write types as follows.

type Partialize<
  P extends any[],
  A extends any[]
> = 0 extends P["length"]                 
  ? []                                1️⃣
  : 0 extends A["length"]                 
  ? P                                 2️⃣
  : [P, A] extends [
      [infer PH, ...infer PT],
      [infer AH, ...infer AT]
    ]
  ? AH extends undefined                 
    ? [PH, ...Partialize<PT, AT>]     3️⃣
    : [...Partialize<PT, AT>]         4️⃣
  : never;
Enter fullscreen mode Exit fullscreen mode

The type definition is very similar in style to TypesMatch, though the results vary.

  • 1️⃣ if P is empty (all parameters were provided) the result is empty as well
  • 2️⃣ if A is empty (no arguments, all parameters are pending), the result is P
  • 3️⃣ if the first type in A is undefined, the result will be the first type in P (because it wasn´t provided) followed by the result of processing the rest of the types in P and A.
  • 4️⃣ otherwise, if the first type in P matches the first type in A, the result will be whatever is returned by comparing the rest of the types in P to the rest of the types in A

We can check how Partialize works:

type part1 = Partialize<
  [boolean, number, string],
  [undefined, undefined, undefined]
>; // boolean,number,string
type part2 = Partialize<
  [boolean | string, number, string],
  [undefined, number, undefined]
>; // boolean|string,string
type part3 = Partialize<
  [boolean | string, number, string],
  [boolean, undefined, string]
>; // number
type part4 = Partialize<
  [boolean, number, string],
  [boolean, number, string]
>; // empty!
Enter fullscreen mode Exit fullscreen mode

Session Replay for Developers

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — an open-source session replay suite for developers. It can be self-hosted in minutes, giving you complete control over your customer data.

OpenReplay

Happy debugging! Try using OpenReplay today.


Complete typing

Now that we have a way to check that the parameters and arguments match, and also how to calculate the still pending parameters, we can actually write our Partial type declaration.

In the definition below, P will represent the types of the function's parameters, A the types of the provided arguments, and R the type of the function's result.

type Partial<P extends any[], R> = <A extends any[]>(
  ...x: A
) => TypesMatch<P, A> extends never
  ? never
  : P extends any[]
  ? 0 extends Partialize<P, A>["length"]
    ? R
    : Partial<Partialize<P, A>, R>
  : never;
Enter fullscreen mode Exit fullscreen mode

How does this work?

  • if the types in P and A do not match, we return a never result, which means there was a problem
  • if A is empty (no arguments provided) then our partialized function will take parameters of type P and produce a result of type R
  • if A is not empty, then we compute the result of comparing types P and A, and that will be the types of the parameters to our partialized function, that will produce a result of type R

This data type definition would be very hard to understand on its own, but since we already saw all its components, it's understandable.

Finish the job

OK, it seems we're ready to finish now! Here's the full definition of partial(...) in TypeScript -- and we'll have to deal with another typing detail! The issue will be that TypeScript, though usually being able to deduce types on its own, isn't able to work out how our function works and what it returns, so we'll have to do a trick to help it.

function partial<P extends any[], R>(              1️⃣
  fn: (...a: P) => R
): Partial<P, R>;                                  
function partial(fn: (...a: any) => any) {         2️⃣ 
  const partialize =
    (...args1: any[]) =>
    (...args2: any[]) => {
      for (
        let i = 0;
        i < args1.length && args2.length;
        i++
      ) {
        if (args1[i] === undefined) {
          args1[i] = args2.shift();
        }
      }
      const allParams = [...args1, ...args2];
      return allParams.includes(undefined) ||
        allParams.length < fn.length
        ? partialize(...allParams)
        : fn(...allParams);
    };
  return partialize();
}
Enter fullscreen mode Exit fullscreen mode

We are defining the type of partial() twice: once using our typing 1️⃣ and the second time 2️⃣ with generic any values. The issue is that TypeScript cannot "understand" the function enough to work out the result, so we "overload" the type definition, and then it's our responsibility to ensure data types are correct!

Let's finish with a few examples of fully typed partial application at work.

const nonsense = partial(function (
  a: number,
  b: string,
  c: boolean
) {
  return `${a}/${b}/${c}`;
});

const ns1 = nonsense(undefined, "9", undefined); 
// type: Partial<[number, boolean], string>

const ns2 = nonsense(22, "9", undefined); 
// type: Partial<[boolean], string>

const ns3 = nonsense(22, "9", true); 
// type: string -- its value is "22/9/true"

const ns4 = nonsense(undefined,"X",undefined)(undefined,false);
// type: Partial<[number], string>
Enter fullscreen mode Exit fullscreen mode

We can check types:

  • ns1 fixed the 2nd parameter of the nonsense function, so we now have a [number,boolean] to string function.
  • ns2 fixed two parameters, so the result is a [boolean] to string
  • ns3 fixes all parameters, so the result is a plain string
  • ns4 fixes parameters in two steps; the result is a [number] to string function

Conclusion

This was an example of complex typing; we needed to use recursion instead of loops, ternary operators instead of alternative structures, and produce a type instead of returning a value. We also saw that, even with all this work, TypeScript can have problems determining types, but there is a way out.

Top comments (0)