Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript static methods in interfaces

I'm looking for a way to require a class to own some static methods without having to implement them by myself (like an interface can define normal methods). Since interfaces do not support static methods, the following code does not work.

interface MyInterface {
  static fromJSON(json: string): MyInterface
  toJSON(): object
}

Abstract classes are not the thing I want, because they do not require the developer to write the method himself, but I have to implement it.
Is there something similar to this, without having to write lots of custom logic?

Using the interface from above, the following implementation should not be accepted:

class MyClass implements MyInterface {
  // Missing static method "fromJSON"
  toJSON() {
    return {}
  }
}

Neither should this one:

class MyClass implements MyInterface {
  static fromJSON(json: string) {
    return 123 // Wrong type
  }

  toJSON() {
    return {}
  }
}

But this one should be accepted:

class MyClass implements MyInterface {
  static fromJSON(json: string) {
    return new MyClass()
  }

  toJSON() {
    return {}
  }
}
like image 686
Tracer69 Avatar asked Mar 01 '23 18:03

Tracer69


1 Answers

There really isn't much support in TypeScript for constraining the static side of a class. It's a missing feature; see microsoft/TypeScript#14600 for an overall feature request, as well as microsoft/TypeScript#33892 for just the "support static implements on classes" part, and microsoft/TypeScript#34516 for just the "support abstract static class members" part.


One big blocker for something like static members of an interface of the form you've shown is that it's hard for the type system to make sense of it in a way that will actually do what you want. There's a longstanding open issue, microsoft/TypeScript#3841, asking that the constructor property of a class should be strongly typed. Currently it only has the type Function:

class Foo {
  instanceProp: string = "i"
  static staticProp: string = "s"
}
const foo = new Foo();
foo.constructor.staticProp; // error!
// -----------> ~~~~~~~~~~ 
// Property 'staticProp' does not exist on type 'Function'

There are some sticky reasons for why this cannot be easily done, spelled out in the issue, but essentially the problem is that subclass constructors are not required to be true subtypes of parent class constructors:

class Bar extends Foo {
  subInstanceProp: string;
  constructor(subInstanceProp: string) {
    super();
    this.subInstanceProp = subInstanceProp;
  }
}
const bar = new Bar("hello");

Here, the Bar constructor is of type new (subInstanceProp: string) => Bar, which is not assignable to the type of the Foo constructor, which is new () => Foo. By extends, bar should be assignable to Foo. But if bar.constructor is not assignable to Foo['constructor'], everything breaks.

There might be ways around that, but nothing has been implemented so far.

All this means that there's no way to look at an object of type MyInterface and be sure that the thing that constructed it has a fromJSON method. So having static inside interface definitions doesn't really behave in any useful way.


The requests in microsoft/TypeScript#33892 and microsoft/TypeScript#34516, don't have this problem. If you could write this:

class MyClass implements MyInterface static implements MyInterfaceConstructor {
// not valid TS, sorry ------------> ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  toJSON() { return "" };
  static fromJSON(json: string) { return new MyClass() };
}

or this:

abstract class MyAbstractClass {
  abstract toJSON(): string;
  abstract static fromJSON(json: string): MyAbstractClass
// ------> ~~~~~~
// not valid TS, sorry
}

you'd have a way to do this. Alas, neither of those features have been implemented as of TS4.1, so the only way to proceed is with workarounds.


Let's take the MyInterface and MyInterfaceConstructor interfaces I wrote above and see what we can do with them. Right now we can only constrain the instance side via implements MyInterface:

class MyClass implements MyInterface {
  toJSON() { return "" };
  static fromJSON(json: string) { return new MyClass() };
}

We can't write static implements MyInterfaceConstructor. But we can make a no-op helper function called staticImplements and call it:

function staticImplements<T>(ctor: T) { }

staticImplements<MyInterfaceConstructor>(MyClass); // okay

The fact that this compiles with no error is your guarantee that MyClass's static side is acceptable. At runtime this is a no-op, but at compile time this is valuable information. Let's see what happens when we do it wrong:

class MyClassBad implements MyInterface {
  toJSON() {
    return ""
  }
}
staticImplements<MyInterfaceConstructor>(MyClassBad); // error!
// ------------------------------------> ~~~~~~~~~~
// Property 'fromJSON' is missing in type 'typeof MyClassBad' 
// but required in type 'MyInterfaceConstructor'.

class MyClassAlsoBad implements MyInterface {
  static fromJSON(json: string) {
    return 123 // Wrong type
  }
  toJSON() {
    return ""
  }
}
staticImplements<MyInterfaceConstructor>(MyClassAlsoBad); // error!
// ------------------------------------> ~~~~~~~~~~~~~~
// The types returned by 'fromJSON(...)' are incompatible between these types.
function validMyClass(ctor: MyInterfaceConstructor) { }

Those are the errors you were looking for. Yes, the static constraint and the errors are not located exactly where you want them in the code, but at least you can express this. It's a workaround.

There are other versions of this workaround, possibly using decorators (which are sort of deprecated or on hold until decorator support in JS is finalized), but this is the basic idea: try to assign the constructor type to the "static part" of your interface and see if anything fails.

Playground link to code

like image 154
jcalz Avatar answered Mar 06 '23 13:03

jcalz