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;
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.