Home > Software engineering >  Dynamic field assignment based on generic string union
Dynamic field assignment based on generic string union

Time:11-30

So I want a function that'll add fields and functions to the object based on provided arguments.
The problem is that in the function it doesn't recognise types of these dynamic fields.
The simplest example showing the problem:

type Extended<Base extends object, Name extends string> =
  Base & Record<Name, string> & Record<`${Name}Something`, boolean>;

const addFields = <Base extends object, Name extends string>(
  obj: Base, names: ReadonlyArray<Name>
): Extended<Base, Name> => {
  return names.reduce((acc, name) => {
    acc[name] = "test"; // error!
    //~~~~~~~
    // Type 'string' is not assignable to type 'Extended<Base, Name>[Name]'.(2322)
    acc[`${name}Something`] = true; // error!
    //~~~~~~~~~~~~~~~~~~~~~
    // Type 'boolean' is not assignable to type 
    // 'Extended<Base, Name>[`${Name}Something`]'.(2322)
    return acc;
  }, { ...obj } as Extended<Base, Name>)
}

const test = addFields({ x: 123 }, ["y"]);
test.x;
test.y;
test.y = "test2";
test.ySomething = false;

TS playground

Is there a way to make typescript recognise these dynamic fields in the function properly or type it differently to avoid that problem while still maintaining type safety?

CodePudding user response:

The compiler isn't very good about verifying assignability to properties of intersections of generic mapped types, unfortunately. There have been some issues about this filed in GitHub, such as microsoft/TypeScript#38796, but I haven't seen an authoritative comment about why exactly this happens.

Note that one complicating factor is that it's actually sometimes quite difficult to work out such types, even for a human being. Your type

type Extended<Base extends object, Name extends string> =
  Base & Record<Name, string> & Record<`${Name}Something`, boolean>;

does strange things if Name is a union of string literal types where one of the members is equal to another member with "Something" appended to it. Like:

type Hmm = Extended<{}, "a" | "aSomething">;
// type Hmm = never

since you get {} & {a: string, aSomething: string} & {aSomething: boolean, aSomethingSomething: boolean}, so the aSomething property would need to be the impossible string & boolean intersection, and the whole object reduces to the never type. Thus it might be technically correct for the compiler to complain about acc[name] = "test", if name is "aSomething", then "test" fails to be string & boolean.

I think these issues could probably be handled by the TS team if they ever tackled such things. For now, we should just approach it as a missing feature and work around it.


My preferred workaround in cases like this is to widen the intersection type to one of its members (if x is of type A & B then it should be safe to widen x to either A or B) before doing the property access. For the code in this question, that looks like:

const addFields = <Base extends object, Name extends string>(
  obj: Base, names: ReadonlyArray<Name>
): Extended<Base, Name> => {
  return names.reduce((acc, name) => {
    const acc1: Record<Name, string> = acc; // okay
    acc1[name] = "test"; // okay
    const acc2: Record<`${Name}Something`, boolean> = acc; // okay
    acc2[`${name}Something`] = true; // okay
    return acc;
  }, { ...obj } as Extended<Base, Name>)
}

First I widen the type of acc to Record<Name, string> in the acc1 variable, which then allows the writing of a string value to its name property. Then I widen the type of acc to Record<`${Name}Something`, boolean> in the acc2 variable, which then allows the writing of a boolean value to its `${name}Something` property.

This all type checks, because instead of trying to evaluate (Record<K1, V1> & Record<K2, V2>)[K1], the compiler can evaluate the more straightforward Record<K1, V1>[K1] to get V1.

Again, it's possibly unsound when name itself ends with "Something", but I'm not too worried about that edge case here.

Playground link to code

  • Related