Ognjen Mišić
Ognjen Mišić

Reputation: 1416

Copy a value based on arbitrary property name

I have a type A with several properties which can be of different keys: strings, numbers, types B, irrelevant. I want to copy over certain fields from object a1 being type A to object a2, that is also of type A, but I do not know in advance which fields i want to copy over. The fields that I want to copy over I get in an array of string literals which tell me which field i want to copy.

type A = {
    name: string,
    length: number,
    thing: B
}

const a1: A = {
    name: "test",
    length: 2,
    thing: { whatever: true },
}

const a2: A = {
    name: "",
    length: 0,
    thing: {whatever: false}
}

const propNames = ["name", "length"]

propNames.map(propName => a2[propName] = a1[propName as keyof A])

I would appreciate any help possible, here's the example on sandbox:

https://codesandbox.io/s/typescript-playground-export-fjol9

Upvotes: 0

Views: 137

Answers (3)

Ognjen Mišić
Ognjen Mišić

Reputation: 1416

What I ended up using is casting my a1 and a2 objects as A & Record<string, any> as that was the most elegant and for me understandable solution.

const a1 = {
  name: "test",
  length: 2,
  thing: { whatever: true }
} as A & Record<string, any>;

const a2 = {
  name: "",
  length: 0,
  thing: { whatever: false }
} as A & Record<string, any>;

arr.filter(propName => propName in a2).forEach(propName => (a2[propName] = a1[propName]));

Ofcourse, in the actual production solution I'm using the in type operator to first of all check if the string is an actual field name in my object. if("value" in a2) etc..

Upvotes: 0

dsapalo
dsapalo

Reputation: 1847

  1. Determine an object which contains all the key/values to be copied.
  2. Use reduce for each property you want to copy, starting with an empty object.
  3. Combine the target object with the values you want to overwrite.
function copyPropertiesOver<E>(properties: string[], source: E, target: E): E {
  const valuesToBeCopied = arr.reduce(
    (acc, curr) => ({ ...acc, curr: source[curr as keyof E] }),
    {}
  );
  const targetUpdated = ({ ...target, ...valuesToBeCopied } as unknown) as E;

  return targetUpdated;
}

const a2Updated = copyPropertiesOver<A>(arr, a1, a2);
console.log(a2Updated);

Playground

Upvotes: 0

jcalz
jcalz

Reputation: 327624

If you write const arr = ["some", "strings"] the compiler will infer that arr is of type string[] and it will immediately forget the exact number and values of its contents. This is often the correct behavior, since people do tend to mutate the contents of arrays (even ones which are const can still have arr[0]="assigned"; and arr.push("pushed");).

If you want the compiler to keep track of the particular string literal value types in an array literal you will need to change how you declare the variable. The easiest way to do this, const assertions, was introduced in TypeScript 3.4:

const arr = ["name", "length"] as const;

Now the type of arr is known to specifically be an array of length 2 whose first element must be "name" and whose second element must be "length". From that, if you write arr.map(propName => ...), the compiler will know that propName must be "name" | "length".


If you fix that, the second problem you'll face is this, assuming you're using TypeScript 3.5 or above:

arr.map(propName => a2[propName] = a1[propName]); // error!
//  --------------> ~~~~~~~~~~~~
// Type 'string | number' is not assignable to type 'never'.

The problem here is that the compiler sees the assignment as possibly unsafe because it cannot verify that a2[propName] is setting the same exact property that you're reading from a1[propName]. It's obvious to us because the variable propName is the same, but all the compiler sees is that these accesses use the same key type, which is a union of two possibilities, with four possible outcomes, like this:

const propName2 = arr[Math.random() < 0.5 ? 0 : 1];
const propName1 = arr[Math.random() < 0.5 ? 0 : 1];
a2[propName2] = a1[propName1]; // same error!

The only safe thing you could write to a2[propName] when propName is a union type is the intersection of all possible property types. In this case that's something which is both a string and a number, or string & number, which is impossible, hence never.

In the past I've suggested, see microsoft/TypeScript#25051 a possible way of asking the compiler to test each possible value of propName to verify that it was being safe, but this is not likely to happen.


Luckily there's a way you can proceed here: use a generic callback to map():

arr.map(
  <K extends typeof arr[number]>(propName: K) => (a2[propName] = a1[propName])
);

In this case, the compiler sees that you're assigning a value of type A[K] to a value of type A[K]. This is in theory just as "unsafe" as the prior code, but it's allowed by the compiler and is likely to continue to be so.. So that's how I'd recommend you proceed.


Okay, hope that helps; good luck!

Playground link to code

Upvotes: 2

Related Questions