Reputation: 8308
I am attempting to write a generic function that can toggle a boolean property in any object by key name. I read the release notes for TypeScript-2.8 and thought that conditional types are supposed to solve this type of issue. However, I cannot figure out how to write my function.
My function accepts the object to be modified and the name of the key to modify. To ensure that only keys for boolean properties are passed in, I used the conditional type expression T[K] extends boolean ? K : never
. As I understand it, this should cause an error if I try to pass a key for a non-boolean property because T[K]
would not satisfy extends boolean
. But if I tried to pass a key for a boolean, then it should accept that K
.
However, it seems that even with this conditional, TypeScript does not realize within the function that T[K] extends boolean
must be true. So I can’t assign the value I read from the object back to the object. This results in the first error shown below. The second error is that type inference doesn’t seem to work for my function. In the calls below, only the second one passes TypeScript’s checks so far.
function invertProperty<T, K extends keyof T> (o:T, propertyName:(T[K] extends boolean ? K : never)) {
o[propertyName] = !o[propertyName]; // Type 'false' is not assignable to type 'T[T[K] extends boolean ? K : never]'. [2322]
}
const myObject:IObject = {
a: 1,
b: true,
c: 'hi',
};
invertProperty(myObject, 'b'); // Argument of type '"b"' is not assignable to parameter of type 'never'. [2345]
invertProperty<IObject, 'b'>(myObject, 'b'); // Works, but requires me to type too much.
invertProperty(myObject, 'a'); // Argument of type '"a"' is not assignable to parameter of type 'never'. [2345]
invertProperty<IObject, 'a'>(myObject, 'a'); // Argument of type '"a"' is not assignable to parameter of type 'never'. [2345]
interface IObject {
a:number,
b:boolean,
c:string,
}
I think that if in my type constraint of K extends keyof T
I could somehow also state and T[K] extends boolean
it would do the right thing. It seems to me that it is an issue that I’m trying to use never
in the argument’s type instead of being able to constrain the type parameter. But I can’t find any way to express that.
Any ideas of how to accomplish this with full type safety?
Upvotes: 17
Views: 9464
Reputation: 2114
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:
/**
* 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;
}
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!
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;
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.
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 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
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;
}
Upvotes: 0
Reputation: 25
You can use mapped types to narrow the properties of an existing type to only those of type boolean and then use keyof
. TS Documentation on Mapped Types.
You can achieve this with the following code:
const invert = <T>(entity: T, key: keyof PickProperties<T, boolean>): void => {
entity[key] = !entity[key] as any;
// when removing the any cast you get a compile error:
// Type 'boolean' is not assignable to type 'T[keyof PickProperties<T, boolean>]'.
}
type PickProperties<T, TFilter> = { [K in keyof T as (T[K] extends TFilter ? K : never)]: T[K] }
The only drawback is the any
inside the invert
function. It works from the callers perspective though.
Example on how to use this:
const func = () => {
const myInterface: MyInterface = {
foo: '',
bar: true,
baz: 3
};
console.log(myInterface.bar); // true
invert(myInterface, 'bar');
console.log(myInterface.bar); // false
// invert(myInterface, 'foo'); // error
// Argument of type '"foo"' is not assignable to parameter of type '"bar"'.
}
interface MyInterface {
foo: string;
bar: boolean;
baz: number;
}
Upvotes: 1
Reputation: 1492
Elaborating on @artem's answer (I'd make it a comment if code were readable in comments), you can use generics to generalise the type
further to allow using the trick for properties of any type on any type.
type PropsCoercedToPOrNeverOnO<O, P> = { [k in keyof O]: O[k] extends P ? k : never }[keyof O];
export type PropsOfTypePOnO<P, O> = { [k in PropsCoercedToPOrNeverOnO<P, O>]: O };
interface IObject {
a: number;
b: boolean;
c: string;
}
function dynamicallyUpdateProperty(
o: IObject,
propKey: keyof PropsOfTypePOnO<IObject, string>,
newValue: string,
) {
o[propKey] = newValue;
}
const myObject: IObject = {
a: 1,
b: true,
c: 'hi',
};
dynamicallyUpdateProperty(myObject, 'a', 'world') // type error
dynamicallyUpdateProperty(myObject, 'sdfds', 'world') // type error
dynamicallyUpdateProperty(myObject, 'c', 'world') // compiles
I don't experience the danger @Coderer describes - if I constrain c: 'foo' | 'bar'
on IObject
then dynamicallyUpdateProperty
stops compiling unless I change newValue
to have type 'foo' | 'bar'
as well.
Strangely I can't genericise the dynamicallyUpdateProperty
function further. I'd expect the following to work:
function dynamicallyUpdateProperty<O, P>(
o: O,
propKey: keyof PropsOfTypePOnO<O, P>,
newValue: P,
) {
o[propKey] = newValue; // compile error - TS2322: Type 'P' is not assignable to type 'O[PropsOfTypePOrNeverOnO ]'.
}
But it doesn't, error shown above.
Upvotes: 2
Reputation: 51609
First, you can extract all keys of boolean properties using this construct (which converts keys of non-boolean values to never
and takes a union of all keys/never, using fact that T | never
is T
):
type BooleanKeys<T> = { [k in keyof T]: T[k] extends boolean ? k : never }[keyof T];
Then, to make TypeScript happy about assigning boolean values to properties, you introduce intermediate type which is declared to have only boolean properties (unfortunately TypeScript cannot figure out this part on its own)
type OnlyBoolean<T> = { [k in BooleanKeys<T>]: boolean };
and you declare that generic type parameter of invertProperty
is compatible with OnlyBoolean
(which it is, it may contain extra non-boolean properties but it's OK)
NOTE you might need different versions of the code depending of the version of the compiler, original code in this answer has stopped working with TypeScript 3.2:
// for TypeScript 3.1 or earlier
function invertProperty<T extends OnlyBoolean<T>>(o: T, propertyName: BooleanKeys<T>) {
o[propertyName] = !o[propertyName];
}
// for TypeScript 3.2 or later
function invertProperty<T>(o: OnlyBoolean<T>, propertyName: keyof OnlyBoolean<T>) {
o[propertyName] = !o[propertyName];
}
interface IObject {
a: number;
b: boolean;
c: string;
}
const myObject:IObject = {
a: 1,
b: true,
c: 'hi',
};
invertProperty(myObject, 'b'); // ok
invertProperty(myObject, 'a'); // error
Upvotes: 22