crazyfrog
crazyfrog

Reputation: 247

Type problems with reduce in TypeScript

I have a simple reduce function which would work in JS, but obviously TypeScript doesn't want to allow me use that. I am supposed to be group clients and find how much they paid for their orders. I wanted to at first make a reduce on orders, so I get an object with idOfClient -> Array of prices. Then I wanted to make a reduce on the Array of prices, so I would get the sum of the array, but it seems like TypeScript doesn't know that there is an array as a value of key, it sees idClientsWithPrice as empty Object I guess. With my code I get an error:

Property 'reduce' does not exist on type 'never'.

My code:

export const summaryOfClient = (state: RootStore) => {

    const idClientsWithPrices = state.orders.orders.reduce((acc,curr) => {
        if (acc[curr.client as keyof typeof acc]) {
            return {...acc, [curr.client]: [...acc[curr.client as keyof typeof acc], curr.price]}
        }
        return {...acc, [curr.client]: [curr.price]}

        // acc[curr.client as keyof typeof acc] ?  {...acc, [curr.client]: [...acc[curr.client as keyof typeof acc],curr]} :
        // {...acc, [curr.client]: [curr]}
    }, {})
    const idClientsWithSum = Object.keys(idClientsWithPrices).map(id => {
        return {
            id: id,
            sum: idClientsWithPrices[id as keyof typeof idClientsWithPrices].reduce((acc: number,curr: number) => {
                acc+curr
            }, 0)}
    })
}

Is there any way to handle that? My second question would be also why the commented code is not equivalent to my if statement? I wanted to do it using tenary operator, but it says that it was not an expression or call

Upvotes: 2

Views: 8226

Answers (1)

Ma3x
Ma3x

Reputation: 6549

The source of the problem for the error you are getting is in the first reduce call, because the initialValue parameter (the value {}) determines the return type of the first reduce call and also the type of its acc parameter. Since you are providing just the value {} as the initial value, without specifying any type along with it, TS correctly assigns it the type of {}. That is also why you had other errors to take care of before you ended with the code that you have now. All those as keyof typeof acc would not be needed, if this initial value would have a type specified along with it.

So let's define the type that we expect the {} to be. Since we want to make a map/dictionary/LUT (lookup table) from scratch, it will clearly start empty, so the initial value of {} is correct. The type of this value should be an object that we can index with a key and the values at these keys should have a type of an array of numbers. However when we index a key that does not exist, we will get undefined back.

So the type is then

{} // for the initial value
as { [key: <key-type>]: undefined | <value-type> }
// an object that we can index with a key and returns undefined or a value

Here I assume that your key is of type string | number. In TS an index signature parameter type must be string, number, symbol, or a template literal type anyway. If we put the actual types in, we get

{} as { [key: string | number]: undefined | Array<number> }  

If you don't want to repeat this type every time you need it, you can write it down somewhere in your code base as a type with a generic type parameter, so that it is reusable for other value types as well.

type Lookup<T> = { [key: string | number]: undefined | T }

(TS also has the Map<KeyType, ValueType> type, but that one is for when you use an actual JS Map with new Map() and allows the keys to be of any type, and has a get(key: KeyType) and set(key: KeyType, value: ValueType) API)

Then you can use it anywhere in your code like {} as Lookup<Array<number>> or {} as Lookup<number[]> (or whatever other type you might need).

As you can see this Lookup<T> type assumes that not every key will return a value, thus the type of values is undefined | T. This is to ensure that TS will force you to check that the returned values are not undefined. You might be tempted to use a different type, where all keys have a defined value, but that will cause TS to not force you to cover the cases when the value might be undefined and might cause runtime errors, if some parts of the code are ever refactored and are not covered by exhaustive (unit) tests.

But if you want to use such a type (at your own risk), it would be something like this

type DefinedLookup<T> = { [key: string | number]: T }

So defining the type of the initial value for the first reduce call is the important first step and with that in place we can also get rid of all the as keyof typeof acc and as keyof typeof idClientsWithPrices in the code.

Using the DefinedLookup<number[]> as the type for the initial value, solves the type check problems, uncovering a new error of a missing return type in the second part of the code. If we add the missing return statement, TS is happy again, and we get

type DefinedLookup<T> = { [key: string | number]: T }

const summaryOfClient = (state: RootStore) => {

    const idClientsWithPrices = state.orders.orders.reduce((acc,curr) => {
        if (acc[curr.client]) {
            return {...acc, [curr.client]: [...acc[curr.client], curr.price]}
        }
        return {...acc, [curr.client]: [curr.price]}
    }, {} as DefinedLookup<number[]>)
    
    const idClientsWithSum = Object.keys(idClientsWithPrices).map(id => {
        return {
            id: id,
            sum: idClientsWithPrices[id].reduce((acc: number,curr: number) => {
                return acc + curr
            }, 0)
        }
    })
}

Then if we switch to the Lookup<number[]> type, TS will complain about us not checking for the undefined values. in this case both are false-positives, since we made sure that we are constructing the lookup in such a way so that it will always include the keys that we are iterating over.

To tell TS that we as the developer are sure that those key/value pairs will always exists, we can use the non-null assertion operator, which is a post-fix exclamation mark (!). We have to add it in two places, where TS complains about possible undefined values, and we get

type Lookup<T> = { [key: string | number]: undefined | T }

const summaryOfClient = (state: RootStore) => {

    const idClientsWithPrices = state.orders.orders.reduce((acc,curr) => {
        if (acc[curr.client]) {
            return {...acc, [curr.client]: [...acc[curr.client]!, curr.price]}
        }
        return {...acc, [curr.client]: [curr.price]}
    }, {} as Lookup<number[]>)
    
    const idClientsWithSum = Object.keys(idClientsWithPrices).map(id => {
        return {
            id: id,
            sum: idClientsWithPrices[id]!.reduce((acc: number,curr: number) => {
                return acc + curr
            }, 0)
        }
    })
}

But we could also simplify this part

   if (acc[curr.client]) {
       return {...acc, [curr.client]: [...acc[curr.client]!, curr.price]}
   }
   return {...acc, [curr.client]: [curr.price]}

to just

   return {...acc, [curr.client]: [...acc[curr.client] || [], curr.price]}

Then you don't need the if check or the ternary operator that you have in the commented out code.

And the second part from

    idClientsWithPrices[id]!.reduce((acc: number,curr: number) => {
        return acc + curr
    }, 0)

to

    idClientsWithPrices[id]?.reduce((acc: number,curr: number) => {
        return acc + curr
    }, 0) || 0

or alternatively to

    (idClientsWithPrices[id] || []).reduce((acc: number,curr: number) => {
        return acc + curr
    }, 0)

The difference between using ! versus || [] (or ? with || 0) is that in case that the first reduce ever gets refactored and if we ever access a missing key, the first approach with ! will result in a runtime error (because the ! operator does not emit any additional code, it is just an operator to tell TS that we know what we are doing as developers), the second approach, however, won't result in a runtime error, because the extra "safety" code will make sure that even when the value is undefined the sum will simply default to 0. The approach you choose really depends on what type of behavior you want to achieve in your code.

Another change could be to use Object.entries instead of Object.keys when you know that you will have to access the value anyway. The code is then

const idClientsWithSum = Object.entries(idClientsWithPrices).map(([clientId, clientPrices]) => {
        return {
            id: clientId,
            sum: (clientPrices || []).reduce((acc, curr) => {
                return acc + curr
            }, 0)
        }
    })

From your commented out code (with the ternary operator) I assume that you wanted to pass the whole object along into the lookup table and not just the prices. If you want that, you can just use a different type for the generic type T in Lookup<T>. So first we change {} as Lookup<number[]> to {} as Lookup<Array<typeof state.orders.orders[number]>> and then we fix the first reduce to just return curr instead of curr.price. TS then tells us that there is an error in the second part of the code, which we fix changing the code from curr to curr.price.

The changed code is

type Lookup<T> = { [key: string | number]: undefined | T }

const summaryOfClient = (state: RootStore) => {

    const idClientsWithPrices = state.orders.orders.reduce((acc,curr) => {
        return {...acc, [curr.client]: [...(acc[curr.client] || []), curr]}
    }, {} as Lookup<Array<typeof state.orders.orders[number]>>)

    const idClientsWithSum = Object.entries(idClientsWithPrices).map(([clientId, clientData]) => {
        return {
            id: clientId,
            sum: (clientData || []).reduce((acc, curr) => {
                return acc + curr.price
            }, 0)
        }
    })
}

Run this code and various options from above in TS Playground

Upvotes: 5

Related Questions