6

Similar to:

How to infer a typed array from a dynamic key array in typescript?

I'm looking to type a generic object which receives a map of arbitrary keys to lookup values, and returns the same keys with typed values (like a typed _.mapValues).

The ability to get a singular typed property from an object is documented and works. With arrays, you need to hardcode overloads to typed tuples, but for objects i'm getting a 'Duplicate string index signature' error.

export interface IPerson {
    age: number;
    name: string;
}

const person: IPerson = {
    age: 1,
    name: ""
}

function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
    return o[name];
}

const a = getProperty(person, 'age');
// a: number

const n = getProperty(person, 'name');
// n: string

function getProperties<T, K extends keyof T>(obj: T, keys: { [key: string]: K }) {
    const def: { [key: string]: T[K] } = {};
    return Object.entries(keys).reduce((result, [key, value]: [string, K]) => {
        result[key] = getProperty(obj, value);
        return result;
    }, def);
}

const { a2, n2 } = getProperties(person, {
    a2: 'name',
    n2: 'age'
});

// Result:
// {
//     a2: string | number, 
//     n2: string | number
// }

// What I'm looking for:
// {
//     a2: string, 
//     n2: number' 
// }

How can this be implemented with typescript?

blugavere
  • 107
  • 1
  • 6
  • Sorry, meant to answer to your comment on the other question but forgot about it. Anyway @jcalz gave the exact answer I was thinking of as well, don't think it can be done better :-) – Titian Cernicova-Dragomir Jun 12 '18 at 19:38

2 Answers2

4

As long as it's working at runtime, you can tell TypeScript how to rename keys using mapped types:

type RenameKeys<T, KS extends Record<keyof KS, keyof T>> = {[K in keyof KS]: T[KS[K]]};

function getProperties<T, KS extends Record<keyof KS, keyof T>>(
    obj: T,
    keys: KS
): RenameKeys<T, KS> {
    const def = {} as RenameKeys<T, KS>;
    return (Object.entries(keys) as Array<[keyof KS, any]>)
      .reduce((result, [key, value]) => {
        result[key] = getProperty(obj, value);
        return result;
    }, def);
}

This should behave as you expect in the type system. Highlights: the type of keys is given a type parameter named KS, which is constrained to be Record<keyof KS, keyof T>, which more or less means "I don't care what the keys are, but the property types need to be the keys from T". Then, the RenameKeys<T, KS> walks through the keys of KS and plucks property types out of T related to them.

Finally, I needed to do a few type assertions... def is RenameKeys<T, KS>. The type of the value in [key, value] I just made any, since it's hard for the type system to verify that result[key] will be the right type. So it's a bit of a fudging of the implementation type safety... but the caller of getProperties() should be happy:

const {a2, n2} = getProperties(person, {
  a2: 'name',
  n2: 'age'
});
// a2 is string, n2 is number.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
3

There may be a way to optimize/collapse this typing a little bit, but I've got a function prototype for getProperties that I believe achieves what you are looking for.

What's missing in your definition is a strong return type definition that links the association between a specific key in the keys object and the specific type in the obj object. Because this is missing, everything becomes the union of types, which is the behavior you see above.

The function type I've come up with is:

function getProperties
    <T, K extends keyof T, U extends { [name: string]: K }>
        (obj: T, keys: U):
            {[V in keyof U]: T[U[V]];

The important part here is the return value type: {[V in keyof U]: T[U[V]]}

It specifies that for each key V in the keys object:

  1. V will be a key in the output object

  2. The value type will be a type from the input obj, where the type comes from the key given by the value associated with key V in U.

casieber
  • 7,264
  • 2
  • 20
  • 34