Search code examples
typescript

Type for property which is key of class and the value of it is of desired type


I am unable to define type which should fullfill following needs:

  • It should be key of class method/variable/setter/getter
  • And the value of that key, should be of defined interface

See following example:

interface FirstInterface {
  field1: string;
  field2: number;
}

interface SecondInterface {
  randomField: string;
}

class Class1 {
  variable: string;

  set setter1(value: FirstInterface): void {...}
  get setter1(): FirstInterface {...}
  set setter2(value: SecondInterface): void {...}
  set setter3(value: FirstInterface): void {...}
}

abstract class Class2 {

  constructor(
    class1Instance: Class1
  )
  
  // I need this type to be key of setter in Class1 and the value of that setter should be FirstInterface 
  // expectation: available type for this should be 'setter1' | 'setter3'
  abstract getCorrectKey(): ??

  // my tries
  abstract getCorrectKey(): keyof Class1 & Class1[keyof Class1] extends FirstInterface
  abstract getCorrectKey(): keyof Class1 infer P & Class1[P] extends FirstInterface
  abstract getCorrectKey(): (keyof Class1 infer P) Class1[P] extends FirstInterface
  // this type is working without error, but the const currentState in the method below is
  // of type `never`, so it is not working (I would expect it to be automattically of type FirstInterface)
  abstract getCorrectKey<T extends keyof Class1>(): Class1[T] extends FirstInterface ? T : never

  // the following method should then work correctly without any TS errors
  private setState(state: FirstInterface): void {
   const currentState = this.class1Instance[this.getCorrectKey()];

   this.class1Instance[this.getCorrectKey()] = {
     ...currentState,
     ...state
   }
  }
}


When I would then extend the abstract class2, I would expect following functionality

class InheritingClass extend Class2 {
  override getCorrectKey(): string {
   // here I would expect errors thrown by TS
   return 'random'
   return 'variable'
   
   // here I would expect no errors
   return 'setter1'
   return 'setter3'
  } 
}

Solution

  • edit: Scroll to the bottom for an alternate, cleaner solution. I used this first one for the sake of explanation.

    edit2: As pointed out by @jcalz in the comments, there is currently no way of separating a property's getter and setter from each other. TS Knows the difference, as you can observe by trying to assign a getter's return value to it's setter (will error), but that difference can't be captured in a type declaration AFAIK. The type will always resolve to the type returned by the getter.

    Let's start with the solution, and then we'll break it down:

    abstract getCorrectKey():
      keyof Class1 extends infer T
        ? T extends keyof Class1
          ? Class1[T] extends FirstInterface
            ? T
            : never
          : never
        : never;
    

    Let's think in terms of set theory. We're starting from the universal set (all types, i.e. any). We need to narrow it down to only the keys of Class1, and then further narrow down to the keys with values that extend FirstInterface.

    For the first part, we can use extends and infer. A naive first attempt might look like this:

    abstract getCorrectKey():
      keyof Class1 extends infer T
        ? Class1[T] extends FirstInterface
            //^ ERROR: Type 'T' cannot be used to index type 'Class1'.ts(2536)
          ? T
          : never
        : never;
    

    So, why doesn't this work? Well, we told TS we want a type (T) that keyof Class1 extends (i.e. is at least as specific as). Since keyof Class1 has the type 'setter1' | 'setter2' | 'setter3', it extends all of the following:

    • any (less specific, but includes keyof Class1)
    • string (less specific, but includes keyof Class1)
    • 'setter1' | 'setter2' | 'setter3' (equally as specific as keyof Class1)
    • 'setter1' | 'setter2' | 'setter3' | 'setterNonExistant' (less specific, but includes keyof Class1.

    Take note of that last one: How can it be that 'setter1' | 'setter2' | 'setter3' extends 'setter1' | 'setter2' | 'setter3' | 'setterNonExistant'?

    Well, the former is a subset of the latter. All members of the keyof Class1 union are members of the keyof Class1 | 'setterNonExistant' union, but not all members of keyof Class1 | 'setterNonExistant' are members of keyof Class1.

    Okay, so we need to tell typescript we want anything that keyof Class1 extends which also, itself, extends keyof Class1. In other words, we want to infer the type that is equivilent to keyof Class1, where:

    (a === b) === (b === a)

    Another naive attempt might look like this:

    abstract getCorrectKey():
      keyof Class1 extends infer T extends keyof Class1
        ? Class1[T] extends FirstInterface
          ? T
          : never
        : never;
    

    But now we get never! Why is that? Well, in this line:

      keyof Class1 extends infer T extends keyof Class1
    

    We're actually telling TypeScript "I want you to infer the type that extends keyof Class1 but only if the inferred type also extends Class1.

    As we noted above, keyof Class1 extends much more than keyof Class1, so this conditional fails and we end up going down the never branch.

    So we instead want "the union of all types which keyof Class1 extends, filtered to only those types that extend keyof Class1.

    type InferredKeyofT =
      keyof Class1 extends infer T 
        ? T extends keyof Class1
            ? T
          : never
        : never;
    
    let t: InferredKeyofT;
             ^// `keyof Class1`
    

    Yes, this is a convoluted way of getting keyof Class1 (T), but now that we have it we can pick out the members of T that meet our condition.

    Class1[T] extends FirstInterface
      ? T
      : never
    

    🎉🎉🎉🎉


    Edit: This can also be written another, cleaner way:

    abstract getCorrectKey(): {
      [K in keyof Class1]: Class1[K] extends FirstInterface ? K : never;
    }[keyof Class1];
    

    Here, we're telling TS "Give me an object with only certain keys":

    type FilteredClass1 =
      { [K in keyof Class1]: Class1[K] extends FirstInterface ? K : never }
    

    Then give me the values of the filtered properties

    type FilteredProperties = FilteredClass1[keyof Class1];