Generalizing the answer by @Robert_Elliot
Strangely I can't genericise the dynamicallyUpdateProperty function further.
It is possible!
I explain all the technicallities at the end, but lets first take a look at the code:
Solution:
/**
* From T, pick a set of properties whose keys are of type P.
*/
type PickOfType<T, P> = { [K in keyof T as P extends T[K] ? K : never]: T[K] & P };
function setProperty<T, P>(
obj: T | PickOfType<T, P>,
key: keyof typeof obj,
value: typeof obj[typeof key],
) {
obj[key] = value;
}
Testing:
interface IObject {
a: number;
b: boolean;
c: keyof IObject;
}
const myObject: IObject = {
a: 1,
b: true,
c: 'a',
};
setProperty(myObject, 'a', false); // Error: 'a' is not assignable to type 'b'
setProperty(myObject, 'sdfds', 'foo'); // Error: string is not assignable to 'never'
setProperty(myObject, 'a', 2); // compiles OK!
setProperty(myObject, 'b', false); // compiles OK!
setProperty(myObject, 'c', 'c'); // compiles OK!
Fixing readonly
:
However the above code has one caveat: It doesn't respect readonly
modifiers. :(
For example, lets assume IObject
was declared like this instead:
interface IObject {
readonly a: number;
b: boolean;
c: keyof IObject;
}
We would still be able to assign to a
using the setProperty
method. Ugh.
The fix:
function setProperty<T, V>(
obj: T | PickOfType<T, V>,
key: WritableKeys<typeof obj>, // <-- restrict to writable keys
value: typeof obj[typeof key],
) {
obj[key] = value;
}
type WritableKeys<T> = { [P in keyof T]-?: Equals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }> extends true ? P : never }[keyof T];
type Equals<X, Y> = (<T>() => T extends X ? 1 : 0) extends (<T>() => T extends Y ? 1 : 0)
? (<T>() => T extends Y ? 1 : 0) extends (<T>() => T extends X ? 1 : 0) ? true : false : false;
Explanations:
Here is a breakdown of the type parameters of setProperty
:
obj
: The intersection of type T
and the "picked" version of T
that only contains properties of type P
. We need the intersection here with T
because otherwise the compiler cannot infer what the object type parameter T
to our method should be.
key
: This fixes the mistake of every other answer generalizing key
on the types T
and P
when in fact the obj
instance can be any type T*
that is assignable to T
. Here key
depends directly on the type of the object - nice and simple - and suddenly everything falls into place.
value
: Same logic - its type should depend directly on obj
and key
.
As for PickOfType
, it's almost the same as what @Niklas_Kraßnig suggested, except it also intersects the resulting type of the properties with P
instead of only to T[K]
. We need this intersection with P
for the exact same reason that we needed an intersection with T
on the obj
parameter: otherwise the compiler cannot infer what the type parameter V
to our method should be.
The readonly
fix:
This is a slightly deeper topic, and touches on a kink in the current compiler, namely that during the kind of generic assignment we did above, the compiler doesn't always care about the readonly
modifier. After all if it did strictly enforce this, then the assignment we are doing inside the setProperty
method shouldn't have compiled in the first place.
This is a known issue.
Thankfully a bunch of smart people found a workaround. It relies on the fact that the rules for type-equality that the compiler uses in its conditional-type logic is stricter, and specifically does take the readonly
modifier into account. This is what the Equals<X, Y>
type is about above.
Now armed with the ability to detect the readonly
modifier, we can write the WriteableKeys<T>
type: Using Equals
it checks every property of T
with an alternative version of itself that has been "stripped" of any readonly
modifier. When attempting to strip -readonly
from a property that isn't readonly
nothing happens, so the two would still be equal, and the key is preserved; Otherwise it means that the property was readonly
, and the key is excluded from the resulting WriteableKeys
type.
More info (and smart people) can be found here, and here.
The example errors:
The errors in the examples above resolve "backwards" with regard to the parameter order.
For example, the first error above, 'a' is not assignable to type 'b', is a result of giving a value
of false
, as from that the compiler concludes that the only valid key
for such a value is 'b'. Hence what it's saying can be rephrased as: The specified key
is not in the set of keys of obj
for which the corresponding property can have the specified value
assigned.
The second error mentioning never
is similar: there is no key in obj
that can accept a string value - hence the set of valid keys is never
.
I find this a little funny because probably what you want is for the compiler to tell you what the correct type is for some property, not suggest an alternative property that you could assign your value to, but maybe that's just me. :P
Bonus:
If you would prefer a method that updates the property, and also returns the old value, this is it:
function updateProperty<T, V>(
obj: T | PickOfType<T, V>,
key: WritableKeys<typeof obj>,
value: typeof obj[typeof key],
): typeof value {
const old = obj[key];
obj[key] = value;
return old;
}