How do I require a keyof to be for a property of a specific type?
Asked Answered
P

4

17

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?

Pelisse answered 14/6, 2018 at 6:51 Comment(0)
R
21

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
Rafe answered 14/6, 2018 at 7:18 Comment(3)
I tried this in the Typescript playground but it's shows a compiler error on the assignment of o[propertyName]=!o[propertyName] saying Type 'boolean' is not assignable to type 'T[{ [k in keyof T]: T[k] extends boolean ? k : never; }[keyof T]]'. Stackblitz typescript project does not show the error. My local typescript compiler (3.2.2) also gives the same errorMares
I updated the answer, 3.1 was the last version for which the original code was working. The compiler is changing, the original code was not strictly type safe according to the rules in effect in 3.2.Rafe
This actually does a dangerous coercion: any type that is assignable to the "target" type (boolean, in this case) is treated as the target type. Modify your code so that the type of IObject#b is true instead of boolean and it still compiles without any warnings. My similar question explores other approaches.Shedevil
A
2

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.

Alinealinna answered 25/11, 2021 at 16:20 Comment(1)
I found how to do the generalization, see my answerDaff
L
1

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;
}
Lasagne answered 16/1, 2023 at 13:3 Comment(0)
D
0

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;
}
Daff answered 16/8 at 17:21 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.