Home > Software engineering >  Typescript map specific keys in object
Typescript map specific keys in object

Time:09-27

I'm new to typescript and want to write a pretty simple generic function - it gets an object (possibly partial), gets a list of keys and maps the specified keys (if they exist) to a different value, the rest of the values need to stay the same. I want all that to be type safe.

So in case I have a type:

interface User {
  userID: string;
  displayName: string;
  email: string;
  photoURL: string;
}

I want to be able to have a function mapper which will take an object of type Partial<User> and then a list of keys, like "displayName" | "photoURL" and, for instance, capitalize their property values, and leave the rest of the properties untouched.

Here are some examples of input/output:

// INPUT
const fullUser: User = {
    userID: "aaa",
    displayName: "bbb",
    email: "[email protected]",
    photoURL: "https://eee.fff",
}

const partialUser = {
    userID: "ggg",
    photoURL: "https://hhh.iii",
}

// OUTPUT
const o1 = capsMapper<User, "displayName" | "userID">(fullUser);
const o2 = capsMapper<User, "displayName" | "userID">(partialUser);

// o1 should be
{
    userID: "AAA",
    displayName: "BBB",
    email: "[email protected]",
    photoURL: "https://eee.fff",
}

// o2 should be
{
    userID: "GGG",
    photoURL: "https://hhh.iii",
}

So I wrote this simple generic function:

const mapper = <Type, Keys extends keyof Type>(data: Partial<Type>) => {
  ???
}

and was hoping to call it like mapper<User, "displayName" | "photoURL">(myObject) but I cannot find a way to implement the function body. The problem is, when I'm iterating over the keys of the passed object, I cannot find a way to check if the key is amongst the Keys type passed as a generic type. I also tried to have an additional parameter in the function:

const mapper = <Type, Keys extends keyof Type>(data: Partial<Type>, keys: Keys) => {

or even

const mapper = <Type, Keys extends keyof Type>(data: Partial<Type>, keys: Keys[]) => {

but still checking the key of the passed (possibly partial) object results in compiler errors.

Is it possible to implement such a function?

CodePudding user response:

function mapperArray<T, FilteredProp extends keyof T>(obj: Partial<T>, keyList: Array<keyof T>) {
    const a:any = {};
    (new Set(keyList.filter(key => key in obj))).forEach(key => a[key] = obj[key])
    return a as Pick<T, FilteredProp>;
}
const a = {
    prop1: "dshfyuhe",
    prop2: 154
};
const b = mapperArray<typeof a, 'prop2'>(a, ['prop2']);
console.log(b.prop2);

this is closest i could come up with. I hope this meet requirement, i also know that this can be improved further but currently i doesn't have knowledge and time.

I could not write it in comment with correct formatting so pasting here.

CodePudding user response:

You definitely need to pass in actual key string arguments into the capsMapper() function. The TypeScript compiler is happy to deal with generic type parameters like K extends keyof T, but the static type system, including generics, is erased when TypeScript is emitted to JavaScript, which is what actually runs at runtime. If you don't have any values of type K, then you're probably not going to be able to do anything with them at runtime. So instead of <T, K extends keyof T>(data: T) => ... we'll need something like <T, K extends keyof T>(data: T, ...keys: K[]) => ....

Here's one possible implementation of capsMapper:

const capsMapper = <T extends Partial<Record<K, string>>, K extends keyof T>(
  data: T, ...keys: K[]
) => {
  const d: Omit<T, K> = data;
  const mapped: { [P in K]?: string } = {};
  for (const k of keys) {
    const v = data[k];
    if (typeof v === "string") {
      mapped[k] = v.toUpperCase();
    }
  }

  type WidenString<T> = T extends string ? string : T;
  return { ...d, ...mapped } as { 
    [P in keyof T]: P extends K ? WidenString<T[P]> : T[P] 
  }; 
}

This might be overkill in terms of the typings, but I'll explain it. We only accept as data an object of type T whose keys of type K have string or undefined values. Then we take data and widen its type from T to Omit<T, K>... this is just saying "let's ignore any keys of data that are in K" because we're going to overwrite these anyway. We assign this to d for ease of use.

Then we make an object for just the mapped properties called mapped, whose type is essentially Partial<Record<K, string>>, and for each k in the keys array, we captialize any string properties we find and put it into mapped at the same key.

Finally we spread d and mapped into a new object and return it. The type of that output would be naturally seen as Omit<T, K> & Partial<Record<K, string>>, which is correct, but not as specific as you might like. That's because every key in keys would be seen as possibly missing, even if you know for a fact that it existed in data.

To make it more specific, we need a type assertion to tell the compiler that it is actually of type { [P in keyof T]: P extends K ? WidenString<T[P]> : T[P] }, a mapped type very much like T (the type of data) except that any mapped string properties are widened to string via the WidenString<T> conditional type. This is important in the case that your original T type had properties of string literal type; if you uppercase those, you will not get the same type out.

Here let's test it and I'll show the possible need for that later:

const fullUser: User = {
  userID: "aaa",
  displayName: "bbb",
  email: "[email protected]",
  photoURL: "https://eee.fff",
}

const partialUser: Partial<User> = {
  userID: "ggg",
  photoURL: "https://hhh.iii",
}

const o1 = capsMapper(fullUser, "displayName", "userID");
/* const o1: {
    userID: string;
    displayName: string;
    email: string;
    photoURL: string;
} */
console.log(o1)
/* { "userID": "AAA", "displayName": "BBB",
  "email": "[email protected]", "photoURL": "https://eee.fff" }  */

const o2 = capsMapper(partialUser, "displayName", "userID");
/* const o2: {
    userID?: string | undefined;
    displayName?: string | undefined;
    email?: string | undefined;
    photoURL?: string | undefined;
} */

console.log(o2);
/*  { "userID": "GGG", "photoURL": "https://hhh.iii" } */

That looks reasonable, I think. The output looks right, and the types are the same... o1 is still a User, and o2 is still a Partial<User>. In the case where we have literal types, though, this happens:

interface Foo {
  a?: "x" | "y"
}
const foo: Foo = { a: "x" };
const o3 = capsMapper(foo, "a");
/* const o3: {
    a?: string | undefined; // this is not "x" | "y" anymore, so o3 is not a Foo
} */
console.log(o3)
// {"a": "X"}

Here o3 has an a property which is "X" in uppercase. That means o3 is not a valid Foo, whose a property can only be "x" or "y" in lowercase. And that's why I have that WidenString<T> type in there. It turns the input type {a?: "x" | "y"} into the output type {a?: string}.

If you don't expect this kind of thing to happen, you could make the typing simpler by writing instead:

return { ...d, ...mapped } as T;

and then o3 would be claimed by the compiler to be of type Foo when it isn't.

Playground link to code

  • Related