How to use key of getter property in a generic method signature

Viewed 62

I'd like to define a class whose descendants have a method signature that adapts to a compile-time fixed property, which may be overridden.

For example:

class Parent {
  public get config() {
    return {
      foo: 'lorem',
    };
  }

  public access<K extends keyof this['config']>(key: K): this['config'][K] {
    return this.config[key];
  }
}

class Child extends Parent {
  public override get config() {
    return {
      foo: 'lorem',
      bar: 'ipsum',
    };
  }
}

new Parent().access('foo');
new Child().access('bar');

This example seems to almost work, except TypeScript throws a compiler error on return this.config[key]:

Type 'K' cannot be used to index type '{ foo: string; }'.(2536)

But I'm not sure I understand why this is erroring, since K is defined as being a key of {foo: string;}.

NB: I know this could be solved with generics by defining an interface, but I was interested to see if I could DRY this up, and only define the property (and its shape) on the class itself.

2 Answers

in K extends keyof this['config'], the type resolution is deferred. This means that K is of type { foo: "lorem" } in Parent class, and of type { foo: "lorem", bar: "ipsum" } in Child class.

In the other hand, the type resolution of return this.config[key]; is not deferred. This means that the type of this.config[key] is always { foo: "lorem" }

In Parent class, K is { foo: "lorem" } and this.config is of type { foo: "lorem" }, everything is fine. But in Child class, K is { foo: "lorem", bar: "ipsum" } and this.config is of type { foo: "lorem" }, the types doesn't match. This explains why the compiler emits the following error message:

Type 'K' cannot be used to index type '{ foo: string; }'.(2536)

You can get more infos in the issue discussion filled by Alec: issue #46954

The following snippet works only if Child config extends the return type of Parent config, and use an explicit type casting to make types match.

class Parent {
  public get config() {
    return {
      foo: 'lorem',
    };
  }

  public access<K extends keyof this["config"]>(this: Parent, key: K) {
    return this.config[key as keyof typeof this["config"]];
  }
}

class Child extends Parent {
  public get config() {
    return {
      foo: 'lorem',
      bar: 'ipsum',
    };
  }
}

new Parent().access('foo');
new Child().access('bar');

see playground

According to the issue I raised, this is working as intended.

Direct copy-paste of RyanCavanaugh's answer:


Property access on class fields doesn't get deferred type resolution like you're expecting: the type of this.config is plain { foo: string }, not this["config"]. That causes this example to break, but is necessary for other scenarios to work -- otherwise you couldn't write

class Parent {
  public config = {
    foo: 'lorem',
  };
  public mut() {
    this.config.foo = "ergo";
  }

  public access<K extends keyof this['config']>(key: K): this['config'][K] {
    return this.config[key];
  }
}

since there's no guarantee that the derived type doesn't need a more-specific value of foo

Related