TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Allow static members in abstract classes to reference type parameters

Open hdodov opened this issue 6 years ago • 11 comments

Search Terms

abstract generic class static type parameter

Suggestion

It has been previously concluded in #24018 that referencing class type parameters in the static side is problematic unless that class is meant to be extended. Well, abstract classes are meant to be extended, so it makes sense to allow type parameters to be used?

To quote @andy-ms in #24018:

Without inheritance that wouldn't make much sense:

class Super<T> {
    static m(x: T): void;
}
Super.m(); // What's `T`?

This is a valid point. But with a solution for abstract static members from #34516, this could be refactored like so:

abstract class Super<T> {
    abstract static m(x: T): void;
}

class A extends Super<number> {
    static m(x) {
        console.log(x * 42);
    }
}

class B extends Super<string> {
    static m(x) {
        console.log(x + ' World!');
    }
}

A.m(2);
B.m('Hello');

Use Cases

Pretty much everywhere that instance side types are related to static side types and where instance methods depend on static properties.

Examples

As an example I'll give my personal use case. I have a class with a static defaults property and instances merge it with a constructor argument of the same type but partial. Then, the resulting object is stored in an instance property:

abstract class Base<T> {
    static defaults: T
    config: T

    constructor(options: Partial<T>) {
        this.config = Object.assign({}, (this.constructor as typeof Base).defaults, options);
    }
}

interface Options {
    a: string
    b: number
}

class A extends Base<Options> {
  static defaults = {
      a: 42,    // Type '42' is not assignable to type 'string'.
      b: 'oops' // Type '"oops"' is not assignable to type 'number'.
  };
}

let inst = new A({
    a: 'bar', // OK
    b: 'baz'  // Type '"baz"' is not assignable to type 'number'.
});

inst.config.a = 12; // Type '12' is not assignable to type 'string'.

Checklist

My suggestion meets these guidelines:

  • [x] This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • [x] This wouldn't change the runtime behavior of existing JavaScript code
  • [x] This could be implemented without emitting different JS based on the types of the expressions
  • [x] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • [x] This feature would agree with the rest of TypeScript's Design Goals.

hdodov avatar Oct 23 '19 07:10 hdodov

Duplicate of #32246, #32211, #24018 and others?

j-oliveras avatar Oct 23 '19 07:10 j-oliveras

@j-oliveras those issues are related to using type parameters in all classes (which is problematic). My suggestion is to allow them only in abstract classes, as they are meant to be inherited.

hdodov avatar Oct 23 '19 08:10 hdodov

I think this is really putting the cart before the horse relative to #34516.

Anyway, A and B happen to be both concrete and non-generic, but they could vary on either axis without problem. Is the idea that this code would just be illegal because there's no legal parameter declaration for m ?

abstract class Super<T> {
    abstract static m(x: T): void;
}

class A<U> extends Super<U> {
    static m(x: __??__) {
        console.log(x * 42);
    }
}

RyanCavanaugh avatar Oct 25 '19 22:10 RyanCavanaugh

I think this is really putting the cart before the horse

That's true, but pointing out possibilities that #34516 could open will make it easier to determine it's value and whether it should be implemented, no?

Is the idea that this code would just be illegal because there's no legal parameter declaration for m?

I haven't thought about that case, but it makes sense, yes. After all, abstract classes allow for ambiguity. It's in their name. If you extend an abstract class and don't give concrete types for its parameters, there's still ambiguity left over, i.e. the resulting class should still be abstract.

So if you want to have a class with generics that extends an abstract class, it should be abstract itself. Your example should turn into:

abstract class Super<T> {
    abstract static m(x: T): void;
}

abstract class A<U> extends Super<U> {
    static m(x: U) {
        console.log(x * 42);
    }
}

...and then you use the resulting class:

class B extends A<number> {}

hdodov avatar Oct 26 '19 08:10 hdodov

I'd appreciate this. I'm running into this problem when I try to declare a type parameter for the shape of a config object for plugins in a plugin system I've created for a Discord bot project.

Right now, I have declared type any because different plugins (classes extending from an abstract Plugin class) use different config objects - they're not standardized because different plugins do different things, so a universal interface wouldn't be the right way to go. A type parameter would end up being used for the constructor's config argument, the non-static config member, and the static defaultConfig member (used to define the minimum working config).

It would be great to get my linter off my back the right way by providing actual types instead of shutting it up with rule-ignore comments.

legowerewolf avatar Jan 19 '20 01:01 legowerewolf

I also have a need for this.

RobertAKARobin avatar May 21 '20 15:05 RobertAKARobin

I would also love this feature to be added, but I don't see why it should be limited to abstract members? As long as the super class is abstract, it should be sufficient. Consumption of inherited members is allowed through the non-abstract class.

In my use case I have a generic service for creating, updating, deleting and fetching resources through REST. This service is extended with specific services that modify the path used in the request (and also add their own requests), but TypeScript is unable to correctly type the response.

abstract class ResourceService<T> {
    protected static resourcePath = 'override-me'

    static async fetch(id: string) {
        const url = `api/${this.resourcePath}/${id}`
        return getRequest<T>(url) // 'Static members cannot reference class type parameters.'
    }
}

class UserService extends ResourceService<User> {
    protected static resourcePath = 'users'

    // Additional functions specific to UserService
}

UserService.fetch('123') // GET api/users/123 -> Should be inferred as Promise<User>

aleksre avatar Sep 05 '20 14:09 aleksre

I would also love this feature to be added, but I don't see why it should be limited to abstract members? As long as the super class is abstract, it should be sufficient. Consumption of inherited members is allowed through the non-abstract class.

In my use case I have a generic service for creating, updating, deleting and fetching resources through REST. This service is extended with specific services that modify the path used in the request (and also add their own requests), but TypeScript is unable to correctly type the response.

abstract class ResourceService<T> {
    protected static resourcePath = 'override-me'

    static async fetch(id: string) {
        const url = `api/${this.resourcePath}/${id}`
        return getRequest<T>(url) // 'Static members cannot reference class type parameters.'
    }
}

class UserService extends ResourceService<User> {
    protected static resourcePath = 'users'

    // Additional functions specific to UserService
}

UserService.fetch('123') // GET api/users/123 -> Should be inferred as Promise<User>

I have a very similar use case and I would like this to be implemented. Just commenting to see if this will move forward someday.

antoniogamiz avatar Mar 30 '21 20:03 antoniogamiz

I need this as well, how can we push this forward?

jeffhappily-seek avatar Apr 04 '21 08:04 jeffhappily-seek

I would also love this feature to be added, but I don't see why it should be limited to abstract members? As long as the super class is abstract, it should be sufficient. Consumption of inherited members is allowed through the non-abstract class.

In my use case I have a generic service for creating, updating, deleting and fetching resources through REST. This service is extended with specific services that modify the path used in the request (and also add their own requests), but TypeScript is unable to correctly type the response.

I have a similar use case and was able to workaround this limitation with the mixin pattern. I've adapted your example to the pattern:

abstract class ResourceService {
    protected static resourcePath = 'override-me'
}

function withResourceService<T>() {
  abstract class ResourceServiceMixin extends ResourceService { // Incase you need to rely on `instanceof ResourceService`
    static async fetch(id: string) {
      const url = `api/${this.resourcePath}/${id}`
      return getRequest<T>(url) // 'Static members cannot reference class type parameters.'
    }
  }
  return ResourceServiceMixin;
}

class UserService extends withResourceService<User>() {
    protected static resourcePath = 'users'

    // Additional functions specific to UserService
}

UserService.fetch('123') // GET api/users/123 -> Should be inferred as Promise<User>

Warning, this apparently causes an error if compiler option "declaration": true is set. Due to #35822 😢

frank-weindel avatar Aug 10 '21 22:08 frank-weindel

Huge need for this. I think the fact that abstract classes already are a thing makes this a no brainer.

Here's my use case if anyone's interested. Basically there's some very common patterns that this ORM I use doesn't have built in so I have to create a common static method on the BaseModel. But it would only ever be used by Child classes extending, but I want the children to return the correct arrays of themselves, not the parent. :

import { Model } from "@nozbe/watermelondb/"

export default abstract class BaseModel<T> extends Model {
  static query(...args) : Query<T> {
    return database.get<T>(this.table).query(...args) as Query<T>
  }
}

export default class EntryModel extends BaseModel<EntryModel> {
  static table = "entries"
  @text("siteId") siteId
  @text("title") title
}

// the results are automatically typed correctly as  EntryModel[]
const correctlyTypedResults = await EntryModel.query(Q.where("title", "something")).fetch()
// right now they are incorrectly typed as Model[]

1mike12 avatar May 05 '23 18:05 1mike12

Need this for an implementation of DI. I can't really see how type error is justified in the following example.

class Base<T> {
    static foo = (): T => ...;
}

class Child extends Base<number> {}

const x: number = Child.foo(); // T is unambiguous

The problematic code would be

Base.foo()

but that's precisely the case and location where an error message is required.

reverofevil avatar Jul 07 '24 01:07 reverofevil