yazeedb
yazeedb

Reputation: 133

TypeScript: How to create a type-safe mapObject function?

I created a util function, mapObject, which is Array.map but for objects.

It's used like so...

mapObject(
  (value) => value * 100,
  {a: 1, b: 2, c: 3}
)
// {a: 100, b: 200, c: 300}

Here's the implementation. I got a pretty type-safe version working, but had to silence 2 errors about using obj[key] without an index signature.

type ValueOf<T> = T[keyof T];

export const mapObject = <OldObject, NewValue>(
  mappingFn: (value: ValueOf<OldObject>) => NewValue,
  obj: OldObject
): Record<keyof OldObject, NewValue> =>
  Object.keys(obj).reduce((newObj, key) => {
    // @ts-ignore (Not sure how to work around this)
    const oldValue = obj[key];

    // @ts-ignore (Not sure how to work around this)
    newObj[key] = mappingFn(oldValue);

    return newObj;
  }, {} as Record<keyof OldObject, NewValue>);

What's a better way to code this? The function correctly infers what's being passed in, and the return value has great type-safety. I'd love to remove the @ts-ignores, but without using index signatures as they hurt type-safety.

Upvotes: 1

Views: 3823

Answers (3)

Dziamid
Dziamid

Reputation: 11581

Try ramda's mapObjIndexed.

export function mapObjIndexed<T, TResult, TKey extends string>(
    fn: (value: T, key: TKey, obj?: Record<TKey, T>) => TResult,
    obj: Record<TKey, T>
): Record<TKey, TResult>;

example usage of mapObjIndexed

Upvotes: 0

Sly_cardinal
Sly_cardinal

Reputation: 13003

If you use a for loop instead of reduce then TypeScript is able to correctly track the type of the i iterator variable.

Otherwise, as Psidom pointed out, Object.keys() returns string[] which loses the property:value mapping.

Here's the minimally changed version of your code:

    type ValueOf<T> = T[keyof T];

    export const mapObject = <OldObject extends object, NewValue>(
        mappingFn: (value: ValueOf<OldObject>) => NewValue,
        obj: OldObject
    ): Record<keyof OldObject, NewValue> => {
        let newObj = {} as Record<keyof OldObject, NewValue>;
        for (let i in obj) {
            if (obj.hasOwnProperty(i)) {
                const oldValue = obj[i];
                newObj[i] = mappingFn(oldValue);
            }
        }
        return newObj;
    }

But there are a few different ways of annotating it to achieve the same result, the semantics vary slightly.

Here's an alternative version using mapped types (it looks similar but it's not an index signature).

This might be slightly more flexible because the input object doesn't need to be a Record type with values of the same type.

The implementation is the same, it's just the type annotations which are different:

    type ValueOf<T> = T[keyof T];

    type MapTo<T, U> = {
        [P in keyof T]: U
    }

    function mapObject<T extends object, U>(mappingFn: (v: ValueOf<T>) => U, obj: T): MapTo<T, U> {
        let newObj = {} as MapTo<T, U>;
        for (let i in obj) {
            if (obj.hasOwnProperty(i)) {
                const oldValue = obj[i];
                newObj[i] = mappingFn(oldValue);
            }
        }
        return newObj;
    }

Upvotes: 3

akuiper
akuiper

Reputation: 214957

Object.keys return string[]. One possible solution is to use type assertion on Object.keys as suggested in this stack overflow answer:

(Object.keys(obj) as Array<keyof typeof obj>).reduce((newObj, key) => {
...
})

Typescript playground.

Upvotes: 1

Related Questions