The good old reduce and problems with it in TypeScript

August 07, 2021

Hello everyone!

I want to first tell you why I decided to create this article.

In all of my jobs, I always had the deepest understanding of TS and its features. Therefore I was a person that people come to when they had type errors that they can’t fix.

And out of all possible problems, one of the most often and most confusing was the problem with correctly typing the reduce method of an array. Also, this example could help you understand the right mindset that will help you to debug many other errors that you’ll face.

So here is an example of the problem:

function callFunctions(fnsMap: Record<string, () => string>) {
  return Object.keys(fnsMap).reduce((acc, key) => {
    acc[key] = fnsMap[key]();
  //^^^^^^
  // Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'.
    return acc;
  }, {});
}

Error message may seem very confusing to you if are new to the TS world, but don’t get discouraged, you definitely will get used to them.

Let’s try to understand what’s going on and why this error happens.

To make it more understandable, we could separate this error into 2 parts:

1) Element implicitly has an 'any' type

2) Expression of type 'string' can't be used to index type '{}'.

The second part says that the type string couldn’t be used to index {}. And because of that the element (acc[key]) has is of type any.

Therefore if we fix the second issue, the first will go away.

Now we need to understand why our acc is typed as {}.

For this, let’s look at the lib.dom.d.ts to understand what’s going on out there.

interface Array<T> {
  // ... other methods ...

  // 1 overload, used for calls that don't provide initialValue
  reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;
  // 2 overload, used for calls that provide initialValue of the same type 
  // as elements of the array
  reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;

  // 3 overload, used when we produce a different type 
  // from our array (our case)
  reduce<U>(
    callbackfn: (
      previousValue: U,
      currentValue: T,
      currentIndex: number,
      array: T[]
    ) => U,
    initialValue: U
  ): U;
}

Array.prototype.reduce has 3 overloads, however, in our case we hit the 3rd overload.

So let’s analyze it and try to read our code from a TypeScript compiler perspective.

Our reduce expects us to produce some value that the compiler needs to figure out itself called U.

Also when we pass a callback without any types we basically tell the compiler to figure out and infer correct typing by itself.

But remember that the compiler must figure out what the U is, so when it sees our initialValue which is {}, it has no option but to use it as the U.

function callFunctions(fnsMap: Record<string, () => string>) {
  // not that our reduce callback doesn't have any types
  return Object.keys(fnsMap).reduce((acc, key) => {
    /* ... */
  }, {});
}

Therefore, we end up with acc typed as {}.

Ok, great. But how to fix this?

That’s a great question, my friend.

To correctly type our function we must help TS understand with figuring out what U is.

There are 2 ways of doing it:

// first way
function callFunctions(fnsMap: Record<string, () => string>) {
  return Object.keys(fnsMap).reduce((acc, key) => {
    acc[key] = fnsMap[key]();
    return acc;
  }, {} as Record<string, string>);
}

// second way
function callFunctions(fnsMap: Record<string, () => string>) {
  return Object.keys(fnsMap).reduce((acc: Record<string, string>, key) => {
    acc[key] = fnsMap[key]();
    return acc;
  }, {});
}

If you look at both of these solutions, you could notice that all we do is add typing by hand to one of the places where generic U is used.

As a result, TS infers the correct type from our handwritten types.

And although both of them work, I suggest you use the second type because it will make your code more type-safe. Because when you use as you may accidentally convert one type to another and don’t get any type errors.

Here is an example:

const fnsMap: Record<string, () => string> = {
  d: () => 'hello',
  f: () => 'world',
};

const a: Record<string, number | string> = {
  b: false,
  c: true,
};

const result = Object.keys(fnsMap).reduce((acc, key) => {
  acc[key] = fnsMap[key]();
  return acc;
}, a as Record<string, string>);

type Test = typeof result; // Record<string, string>

As you could see we ended up with result typed as Record<string, string>, which isn’t correct.

However, if we used typed our acc parameter of the callback, we’d end up with a type error:

const result = Object.keys(fnsMap).reduce(
  (acc: Record<string, string>, key) => {
    acc[key] = fnsMap[key]();
    return acc;
  },
  a
);
// Error.
// No overload matches this call.
// ... many lines of error ...
// Type 'number' is not assignable to type 'string'.

Although in this particular case error may seem a bit confusing, it’s better to have any error than to end up with buggy code.

Of course, I showed you a very simple case where it’d be very difficult to make a mistake, but in the real project when you have way more code, it’s simple to forget about such small details.

Quick tip: If you see a big type error, scroll to the bottom and check few last lines, they usually contain the most useful information.

Conclusion

Although we’ve covered only 1 possible case with reduce, all of the errors that you will face usually come for the same reason.

Also, the thinking process that we’ve used to understand the error is very helpful to debug any type error that you’ll face.

So next time you will face a strange type error, try to abstract away from what your code does and try to look at it from the perspective of the compiler. I promise you, it will make your life so much easier.

That’s it for today, if you have any thoughts, suggestions, feedback, or anything else, feel free to hit me up at twitter.

Take care!


© 2023 Ayub Begimkulov All Rights Reserved