TypeScript: static property inheritance complaining when it shouldn't

I’ve seen that you’ve already closed many issues on this. But I’m reporting another one in hopes to wear you down 😉

It seems I can’t change the signature of a static function in a derived class. The fact that the subclass is at all aware of the static functions on the super class is really strange.

I developed in C# for years, so I know what you were trying to do… but given that you don’t support a new keyword like C# does, I think this behavior is really wrong. If you’re looking to target being a superset of ES6/ES7, you need to correct this behavior ASAP. Generally in JavaScript, static properties are not copied to subclasses unless the copy is explicit.

I ran into this issue with RxJS Next, where I Subject must inherit from Observable, but they both need different static create() signatures. So I end up hacking around and fighting TypeScript.

About this issue

  • Original URL
  • State: open
  • Created 9 years ago
  • Reactions: 31
  • Comments: 28 (8 by maintainers)

Commits related to this issue

Most upvoted comments

So… and now there a plans to change this? I mean, this is pretty old now? Plans for a survey or something?

Static factory methods are a useful design paradigm, for instance when object creation relies on data that needs to be asynchronously fetched a static create() method can encapsulate the data fetching, and return the object type (as such fetches cannot be conducted directly in the constructor). This behaviour also aligns with the OOP principles in the popular object oriented languages C# and Java. Requiring that in inheritance each static method with the same name must have compatible types restricts this significantly (for create(), this would enforce the use of the same parameters). Is there an argument for maintaining this restriction?

For example, consider the following:

class Entity {
  id: string;
  name: string;
  constructor(id: string, name: string) {
    this.name = name;
    this.id  = id;
  }

  static async create = (name: string): Promise<Entity> => {
    const { id } = await db.createEntity(name);
    return new Entity(id, name);
  }
}

class Bob extends Entity {
  age: number;
  constructor(id: string, age: number) {
    super(id, 'Bob');
    this.age = age;
  }

  // TS Error: Types of property 'create' are incompatible
  static async create = (age: number): Promise<Bob> => {
    const { id } = await db.createBob(age);
    return new Bob(id, age);
  }
}

How about a new keyword override in the child class, that would explicitly tell the compiler to fuck off?

I still believe this is worth fixing (see my earlier comments), but I’ve come up with a free-after-typechecking workaround that at least unblocks me, so I thought I’d share it here:

type OmitStatics<T, S extends string> =
    T extends {new(...args: infer A): infer R} ?
        {new(...args: A): R}&Omit<T, S> :
        Omit<T, S>;

Example usage:

class Base {}
namespace Base {
  export enum EventType { A }
}
class Sub extends (Base as OmitStatics<typeof Base, 'EventType'>) {}
namespace Sub {
  export enum EventType { B }
}

TypeScript fully understands the correct class relationship, but it doesn’t complain about the otherwise-incompatible override anymore.

Warning: nothing prevents you from calling an incompatibly-overridden static method unsafely (e.g. Base’s static foo() calls this.bar(42) but Sub incompatibly overrides static bar(arg: string)). If you want that extra protection, you could take a broader approach by eliminating all statics from the base class (this has the added “benefit” of enforcing that statics are only called directly on the classes they’re defined on, which I’d consider best practice as long as you’re not relying on the class-side dynamism):

type OmitAllStatics<T extends {new(...args: any[]): any, prototype: any}> =
    T extends {new(...args: infer A): infer R, prototype: infer P} ?
        {new(...args: A): R, prototype: P} :
        never;
class Sub extends (Base as OmitAllStatics<typeof Base>) { /* ... */ }

Playground examples

Recently ran into this as well.

type Class<T> = {
    readonly prototype: T;
    new(...args: any[]): T;
};

class Foo {
    public static create<TFoo extends Foo = Foo>(this: Class<TFoo>, model: Partial<TFoo>): TFoo {
        return new this(model.propertyA);
    }

    public constructor(public propertyA?: string) { }
}

class Bar extends Foo {

    public static create<TBar extends Bar = Bar>(this: Class<TBar>,model: Partial<TBar>): TBar {
        return new this(
            model.propertyB,
            model.propertyA,
        );
    }

    public constructor(
        public propertyB: number,
        public propertyA?: string,
    ) {
        super(propertyA);
    }
}

Intuitively speaking, as Bar#create breaks the prototypal “link”, I find this restriction bit of an annoyance. Refer this playground link.

Contextually, I also tried to use the InstanceType utility class to find a workaround. But w/o much luck. Here is the playground link for that.

Due to the lack of better example, I would like to point out that with C#, similar behavior is possible (just to justify the pattern/example).

using System;

namespace trash_console
{
  class Program
  {
    static void Main(string[] args)
    {
      Bar.Create();
      Foo.Create();
      Console.ReadLine();

      // output:
      //
      // Foo ctor
      // Bar ctor
      // Foo ctor
    }
  }

  internal class Foo
  {
    public static Foo Create()
    {
      return new Foo();
    }

    public Foo()
    {
      Console.WriteLine("Foo ctor");
    }
  }

  internal class Bar : Foo
  {
    public new static Bar Create()
    {
      return new Bar();
    }

    public Bar()
    {
      Console.WriteLine("Bar ctor");
    }
  }
}

I agree that this should at least be a strictness option that can be turned off. In some circumstances people might actually use constructor values and their static methods polymorphically, but I don’t think that’s a widely understood expectation, and this restriction gets in the way of entirely reasonable code such as static constructor methods with the same name but different parameter types.

Please, we need an update on this

So from what I can gather, the reason this issue is dead in the water is because of these concerns.

However, the inaction has come at the cost that you cannot share the name of your static factory methods. As I understand this exact same restriction isn’t applied to constructors, as the issue would appear far more frequently. Which makes sense because it would make using constructors completely intolerable. Well this is an intolerable issue and you simply cannot use static factory methods that will clash. The type gymnast workarounds also all suck, and no one should reasonably incorporate them into their code.

The workaround I wanted to use is probably something like this: pay for the pain in verbose names, use the class name again in your static factory methods such that FooBar.factory = FooBar.FooBarFactory. However, even this sucks. If I wanted the factory to have a short name, for example having a Result static factory on an Error type to wrap them in a Result type, then you’re all out of luck. There are for sure other valid examples where shorter and consistent names are desired.

What makes it acceptable for this issue to be parked? The hope that very few users come across it without quickly finding a suitable workaround, but there does seem to be a lot of confidence in that. Meanwhile I’ll compromise too by using different names.

At the very least, is there any chance we can get the error to appear at the static method level, as opposed to the class? This would allow us to suppress it with // @ts-expect-error without having to suppress errors on the entire class.

The issue is specifically where Observable.create and Subject.create need to have different signatures.

As you can see, I’ve found a workaround, but it’s not ideal.

@shicks reading your workaround again after this explanation just really made it super clear, I can’t upvote this enough. I switch would be nice to to disable this check(or make it appear on the function and just ignore it there), but I really think this could work well for more complex cases we use. Thanks!

*update: Still have an issue with generic classes as now the generic properties simply grabbed from Base, not Base<T>. But trying to figure out something.

Yes, this is the same issue. The declared type of Sub.bulkCreate is not a subtype of the declared type of Base.bulkCreate. TypeScript is expecting the static methods to follow Liskov substitution, so that you could write

Sub.bulkCreate.call(Base);

But as you’ve written your Sub.bulkCreate signature, you’ve constrained the input type M to be narrower in the subclass than in the base class (it requires this to be Sub<ExtendedEntity>, rather than a Base<BaseEntity>). This is somewhat confusing for two reasons: (1) because function parameters are contravariant, so (arg: number) => void is actually a supertype of (arg: unknown) => void, even though number is a subtype of unknown. But also (2) TypeScript plays a little fast and loose with this rule by allowing methods (i.e. using method shorthand rather than a property with a colon) to break this rule, so a lot of people get away with breaking it without realizing what they’re doing.

The reason the error goes away when you remove doSomething is because TypeScript types classes structurally: so without that method the template parameter no longer shows up anywhere in the type, so that Sub<A> === Base<B> for all A and B, as far as the type checker is concerned (and Sub doesn’t add any other properties, either). Removing the extra property from ExtendedEntity would also fix the error, since as you’ve written it Sub<A> === Base<A> already.

Back to the original issue, there should be nothing wrong with what you’ve written. The type checker can see that typeof Sub is not assignable to typeof Base, and can enforce this just fine. If you wanted to make it assignable, you can, but it should let you choose to have it not be assignable (which it’s not). You can use the workaround I posted earlier to remove bulkCreate from Base in the extends clause, but we shouldn’t have to do that.

It’s perhaps worth noting that constructor is already exempt from substitutability checks, and in JS the constructor function is the class, which means in practice there’s a good chance a subclass can’t stand in for its parent class anyway and at that point enforcing that all the static members of the subclass are compatible with the ones in the base class doesn’t actually accomplish much.

Any update on this? i’m stuck, i want to have different parameters on parent static function and in child static function, i don’t see necessary that Typescript does this with static functions

I’d argue that the extends-time enforcement is the fundamental problem. The check ultimately needs to be at the usage site. In your example, the call to getP(C) will typecheck just fine because typeof C is a subtype of typeof A, but this is not just because C is a subtype of A (subtypeness is not invariant across typeof, though that’s a partial requirement): it’s also necessary that the static side be completely compatible. That’s when the type checker should enforce the static side types. And if D extends A is never passed as typeof A then it can define p to be whatever type it wants. (For subtyping compatibility, it’s also necessary that the constructor arguments be compatible; if that’s not needed for some application, a structural type or interface like {p: string|string[]} could be more broadly useful.

The trouble is that inheritance of static methods works differently from inheritance of instance methods. Here is a simplified example based on a real world problem I’ve encountered. GObject has this static method:

static install_property(property_id: number, pspec: ParamSpec): void

GtkSettings is derived from GObject and has this static method:

static install_property(pspec: ParamSpec): void

If they were instance methods, yes that would cause problems whether you were using JS or TS, but as they’re static methods you’re always going to qualify them when you call them eg GtkSettings.install_property(pspec), so there shouldn’t be any confusion over which method gets invoked. I know there can be problems when using this in static methods, but that isn’t an issue here because these are bindings for native code.

However, the only way I can declare Settings’ method in Typescript is by tricking it into thinking it’s an overloaded method ie add declarations for both signatures to Gtk.Settings. This is the exact opposite of what Typescript is supposed to do in terms of type safety etc!

This is causing a number of problems trying to migrate Closure Library to TypeScript. In particular, many of our legacy classes make common use of the pattern

class Component {}
namespace Component {
  export enum EventType { FOO }
}

class Button extends Component {} // TS2417
namespace Button {
  export enum EventType { BAR }
}

But this complains about class-side inheritance since the two EventType enums are incompatible.

In actuality, subclass constructors are not generally substitutable for one another, so enforcing it at the class level seems misguided. In particular, all the existing enforcement does nothing to warn about the following broken code:

class Component {
  private readonly x = 1;
  constructor(readonly componentType: string) {}

  static create(): Component {
    return new this('generic');
  }
}

class Button extends Component {
  private readonly y = 2;
  constructor(readonly size: number) {
    super('button');
    if (typeof size !== 'number') throw Error('oops!');
  }
}

Button.create(); // NOTE: no error, but throws

The correct solution would be to to (ideally) ban unannotated use of this in static methods of (non-final) classes and require explicit specification:

class Component {
  static create<T extends typeof Component>(this: T) { ... }
}

Button.create(); // Error: Button not assignable to typeof Component

This triggers an error at the call site of Button.create() since TypeScript can already tell that Button is not assignable to typeof Component. Given this, it makes sense to loosen up on the requirement that overridden static properties must be compatible. TypeScript’s structural typing is smart enough to know when it matters.

EDIT: clarified the code examples a little