Typescript: Allow specifying interface implements clauses for the static side of classes

Created on 9 Oct 2019  Â·  18Comments  Â·  Source: microsoft/TypeScript

Search Terms

class static side syntax interface type expression

Suggestion

Currently, you can only specify the static side interface of a class with a declaration. From the handbook:

const Clock: ClockConstructor = class Clock implements ClockInterface {
  constructor(h: number, m: number) {}
  tick() {
      console.log("beep beep");
  }
}

When I first wanted to do this (before looking at the docs), I tried to do it in this fashion:

class Clock: ClockConstructor implements ClockInterface {
  ...
}

And I was surprised to see that it didn't work. My proposal is to make this a valid syntax as it's more intuitive and understandable.

I believe that forcing class expressions conflicts with TypeScript's design goals:

  1. Produce a language that is composable and easy to reason about.

Why use a class expression when there is no need for it? Why change your actual JavaScript logic for something that exists only in TypeScript and not in your production code.

Use Cases

Anywhere you need to set the interface of the static side of a class without having a need to specify it as an expression.

Examples

Take the example from the playground:

interface ClockConstructor {
  new (hour: number, minute: number);
}

interface ClockInterface {
  tick();
}

class Clock: ClockConstructor implements ClockInterface {
  constructor(h: number, m: number) {}
  tick() {
      console.log("beep beep");
  }
}

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.
In Discussion Suggestion

Most helpful comment

Ref #14600

We discussed #14600 at length and agreed that the syntax static T in the implements list of a class would be a reasonable place to allow this:

interface X {
  x: string;
}
interface Y {
  y: string;
}
interface Z {
  z: string;
}

// OK example
class C implements Y, static X, Z {
  static x: string = "ok";
  y = "";
  z = "";
}

// Error, property 'x' doesn't exist on 'typeof D'
class D implements static X {
}

Why not static members in interfaces?

14600 proposed the syntax

interface X {
  static x: string;
}

This is problematic for a couple reasons.

First, it seems to create a totally meaningless thing:

interface X {
  static x: string;
}
function fn(arg: X) {
  // arg has... no members?
}
// Is this a legal call?
// There doesn't seem to be any reason to reject it,
// since 'fn' can't illegally access anything that doesn't exist
fn({ });

Second, there would be no way to access a static member through an interface type that declared it!

interface X {
  static x: string;
}
function fn(arg: X) {
  // How do I get to ctor(arg).x ?
}

You would need to have some auxiliary keyword to turn an interface into the static version of itself:

interface X {
  static x: string;
}
// Syntax: ????
function fn(arg: unstatic X) {
  arg.x; // OK
}

Simply moving the static logic to the class syntax makes this substantially simpler, and (critically) allows classes to implement types that weren't written using the hypothetical interface I { static property: T } syntax.

Why implements static T, not static implements T ?

In the presumably-usual case of one instance and one static heritage member, this is substantially cleaner:

class WidgetMaker implements Factory, static FactoryCtor {
  // ...
}

vs

class WidgetMaker implements Factory static implements FactoryCtor {
  // ...
}

All 18 comments

Ref #14600

We discussed #14600 at length and agreed that the syntax static T in the implements list of a class would be a reasonable place to allow this:

interface X {
  x: string;
}
interface Y {
  y: string;
}
interface Z {
  z: string;
}

// OK example
class C implements Y, static X, Z {
  static x: string = "ok";
  y = "";
  z = "";
}

// Error, property 'x' doesn't exist on 'typeof D'
class D implements static X {
}

Why not static members in interfaces?

14600 proposed the syntax

interface X {
  static x: string;
}

This is problematic for a couple reasons.

First, it seems to create a totally meaningless thing:

interface X {
  static x: string;
}
function fn(arg: X) {
  // arg has... no members?
}
// Is this a legal call?
// There doesn't seem to be any reason to reject it,
// since 'fn' can't illegally access anything that doesn't exist
fn({ });

Second, there would be no way to access a static member through an interface type that declared it!

interface X {
  static x: string;
}
function fn(arg: X) {
  // How do I get to ctor(arg).x ?
}

You would need to have some auxiliary keyword to turn an interface into the static version of itself:

interface X {
  static x: string;
}
// Syntax: ????
function fn(arg: unstatic X) {
  arg.x; // OK
}

Simply moving the static logic to the class syntax makes this substantially simpler, and (critically) allows classes to implement types that weren't written using the hypothetical interface I { static property: T } syntax.

Why implements static T, not static implements T ?

In the presumably-usual case of one instance and one static heritage member, this is substantially cleaner:

class WidgetMaker implements Factory, static FactoryCtor {
  // ...
}

vs

class WidgetMaker implements Factory static implements FactoryCtor {
  // ...
}

@RyanCavanaugh I understand your points and agree with all of them. But I don't understand what's the issue with my proposed syntax without the static keyword. I mean, instead of this:

class C implements Y, static X, Z { }

Why not this:

class C: X, Z implements Y { }

Your syntax could be read the correct way like this:

class C implements (Y), static (X, Z)

but due to the commas, it could also be read like this:

class C (implements Y), (static X), (Z)

which would make Z appear out of place. It could even be read like this:

class C implements (Y, static X, Z)

as if C implements Y, Z and a _static_ X which makes no sense. I think the issue here is that the comma is used as both a separator for symbols _and_ keywords.

My proposal has a clear separation of the static and instance sides:

class |C: X, Z| implements |Y, T| { ... }
------|static--------------|instance-----

Yours doesn't:

class |C| implements |Y, T|, static |X, Z| { ... }
------|static--------|instance------|static-------

I just think my proposal leaves less ambiguity.

Some other benefits:

  • No use of the static keyword. It was designed for usage inside the class body and if you avoid using it, you'll avoid assigning another TypeScript-only meaning to it
  • Members of X and Z are visually closer to C, which makes more sense since you'll be using C.memberOfX and C.memberOfZ
  • It's consistent with other keywords. You've got let foo: string and const bar: number. Adding class C: X {} simply follows that pattern
  • It's shorter

And to clarify, I'm talking about syntax only. No functional differences.

: in TypeScript always means "is of type", but implements has a different meaning ("is assignable to"). IOW the implied semantics of : are

interface P {
  x: number;
}
class C: P {
  static x: number;
  static y: number;
}
C.x; // OK
C.y; // Not OK; only members of P are visible

the same way that this works (disregarding excess property checks):

interface P {
  x: number;
}
const C: P = { x: 0, y: 0 };
C.x; // OK
C.y; // Not OK; only members of P are visible

I see. To clarify, would static act as a modifier? If I have:

class C implements Y, static X, Z { }

I should read it as "C implements interface Y, interface X as static, and interface Z" instead of "C implements interface Y and interfaces X and Z as static," right?

When I initially read your comment, I thought static would act like implements and you list items after it. Instead, it would act as a modifier to the items _of_ implements?

So if I have an interface X that must be type-checked in both the instance side and the static side, I would do:

class C implements X, static X { }

Is that right?

I don't really follow Ryan's goals in his second post here.

it seems to create a totally meaningless thing: // arg has... no members?

If you made an class with only static methods then you couldn't do much with instances of it either.

there would be no way to access a static member through an interface type that declared it

I don't think that's the point. The reason I want to see static in an interface is to avoid needing to split a mix-in definition:

````ts
interface Widget {
static create(): Widget;
x: number;
}

class A implements Widget {
static create(): A { ... }
x: number = 1;
}

function factory(clazz: {create(): Widget}): Widget {
const ret = clazz.create();
ret.x = 10;
return ret;
}
````

This is cleaner than the alternative, where I'd have to have separate a WidgetConstructor interface and static implement it. That feels clunky and leads to the potential confusing cases @hdodov lists a few posts back, where there isn't really a "right" way to visually parse the sequence of keywords.

@thw0rted the constructor and the instance are two completely separate entities. In JavaScript, the API of one is not connected in any way to the API of the other. Because they have different APIs, they probably function differently as well—you can't use a constructor as an instance and vice-versa. Therefore it makes sense to define their types with two separate interfaces.

Your example could be rewritten as:

interface Widget {
  x: number
}

interface WidgetCtor {
  create(): Widget
}

class A implements Widget, static WidgetCtor {
  static create(): A { ... }
  x: number = 1;
}

I personally think that's better.

Even in your example, the Widget interface has a method create() that should return a Widget interface... that should also have create() and return a Widget with create() and so forth? Separating those types in two interfaces solves that problem.


Also, what if you have an object that is Widget-like? If you do let obj: Widget it becomes really confusing that this object uses an interface that has a static create() method. By adding a static member to an interface, you imply that it has a static and an instance side and can therefore be used in classes only. Why limit yourself like that? By specifying two interfaces, you avoid that problem too.

You say they're not connected in any way, but you still write both constructor (static) methods and instance methods inside the same class { } block, right? That's what I'm saying in my previous comment. There's a certain parallelism in the single-interface version, one interface that describes (a portion of) one class, that's lacking in the two-interface version you describe.

As for the static create() method of let obj: Widget, can't I call obj.constructor.create() if I want to? I'm not an expert in how ES6 classes differ from constructor functions with prototypal inheritance, but I can say that this at least works in Chrome and Node, whether or not it's "correct".

but you still write both constructor (static) methods and instance methods inside the same class { } block

Yes, because in terms of JavaScript, you have no reason to separate them. When a class extends another class, it extends both the instance and the static side. Don't forget that classes are just syntactic sugar. In the end, all static members appear on the class (constructor) itself, while the instance methods appear in its prototype. So they are separated in JavaScript as well.

There's a certain parallelism in the single-interface version, one interface that describes (a portion of) one class

Yes, but what value does that give you? You can't describe _one_ class. You describe one constructor _and_ one prototype. You describe two things at once which, in the realm of types, complicates things. Logically, they describe "one class" but they serve a different purpose. One might even say, they're different _types_. And types is what TypeScript is interested in.

can't I call obj.constructor.create()

Yes, you can. But that's different from obj.create(). A class X with static create() would have its method called with X.create(). Saying an object has the same interface would imply that it should have obj.create(), right? But (back to your example) if static properties are assigned to obj, where should the instance member x be expected? obj.x? Why are suddenly static and instance referring to the same thing? Because we united them in an interface and we shouldn't.

@thw0rted Notice these two lines in your code:

interface Widget {
  static create(): Widget;
//     ~~~~~~~~~~~~~~~~~~
  x: number;
}

class A implements Widget {
  static create(): A { ... }
  x: number = 1;
}

function factory(clazz: {create(): Widget}): Widget {
//                      ~~~~~~~~~~~~~~~~~~
  const ret = clazz.create();
  ret.x = 10;
  return ret;
}

This repetition is exactly what we want to avoid - you defined static create in interface Widget, and have no way to reference that declaration to define the shape of clazz.

Don't forget that classes are just syntactic sugar. In the end, all static members appear on the class (constructor) itself, while the instance methods appear in its prototype.

ES6 already has first-class support in modern runtimes.

// In Chrome dev tools console class XYZ { static a(){return 1;} } let x = new XYZ(); "" + x.constructor; // "class XYZ { static a(){return 1;} }"

It might just be syntactic sugar once you're down in the guts of the engine, but all the seams have been fully plastered over when viewed from the outside. The whole point of ES6 classes is to stop the dichotomy of "one constructor and one prototype".

I don't follow your last paragraph about "where instance member x is expected". An instance of a class that implements an interface with static properties would not have those properties, because that's not what static means. Maybe a more concrete example would clarify your concerns?

Ryan, I take your point about duplication. You're saying that having a separate name for the static/constructor interface allows us to reference it easily in the function parameter type. The cost of your solution is adding a new keyword for "implements static" or similar, which introduces the ambiguities discussed in comment 3 above.

What if instead we could make a conditional type that took an interface with static properties, and returned an interface with only the static properties except now they're not static? I've never had the knack for the type math needed to make complex transformations in the type space -- I won't bother Dragomir with an at-mention again, but he knows what's up -- but maybe it's possible today. If not, maybe adding type operators to make it possible would be a useful contribution to the language. Imagine:

````
interface Widget { static create(): Widget; x: number; }
type StaticShape = ✨ ; // magic!

type WidgetConstructor = StaticShape; // === interface { create(): Widget }

function factory(clazz: WidgetConstructor): Widget { ... }
````

Bonus points if the magic can turn a class with constructor(x, y, z) into a new(x, y, z) property, which has been a sticking point for factory-pattern for a while, too. Of course, since I'm hand-waving the hard part here, I recognize that this could be completely impractical, but I figured it's worth asking. And if it works, it has the virtue of avoiding new, potentially-ambiguous keywords while closely mirroring the existing (static class method) syntax.

@thw0rted

ES6 already has first-class support in modern runtimes.

Yes, and it does exactly what I said. When you compile your ES6 class to ES5, the resulting code is pretty much what the browser does with a non-compiled ES6 class. Quoting MDN:

JavaScript classes, introduced in ECMAScript 2015, are primarily syntactical sugar over JavaScript's existing prototype-based inheritance. The class syntax does not introduce a new object-oriented inheritance model to JavaScript.

What modern browsers offer is a way to alter the constructor's prototype without actually assigning to it in your code. It's just a syntactic trick.

The whole point of ES6 classes is to stop the dichotomy of "one constructor and one prototype"

No, their point is to improve the syntax of working with "one constructor and one prototype."

An instance of a class that implements an interface with static properties would not have those properties

Yes, but we were talking about a hypothetical obj which is an _object_ that is Widget-_like_, not a Widget _instance_. If you cram both static and instance members in one place, you can't use them separately:

let widgetLike = {}
widgetLike.x = 'foo' // should error type '"foo"' is not assignable to type 'number'

let widgetConstructorLike = {}
widgetConstructorLike.create = 42 // should error type '42' is not assignable to type '() => Widget'

Could you show how you would implement the types of widgetLike and widgetConstructorLike with an interface like this:

interface Widget {
  static create(): Widget;
  x: number;
}

How do you tell TS that widgetLike should have instance properties, while widgetConstructorLike should have only static properties? You can't do it like this:

let widgetLike: Widget = {}
let widgetConstructorLike: Widget = {}

...because those are identical types. Separating the two sides solves this problem:

interface Widget {
  x: number
}

interface WidgetCtor {
  create(): Widget
}

let widgetLike: Widget = {}
widgetLike.x = 'foo' // error

let widgetConstructorLike: WidgetCtor = {}
widgetConstructorLike.create = 42 // error

The cost of your solution is adding a new keyword for "implements static" or similar, which introduces the ambiguities discussed in comment 3 above.

Actually, it doesn't introduce ambiguities. I simply misinterpreted Ryan's comment. The static keyword would be a modifier to a member of the implements list. It simply denotes that the implemented interface should be part of the static side. Example:

interface Foo { f: number }
interface Bar { b: string }

// Foo and Bar are forced on the instance
class C implements Foo, Bar {
    f = 42
    b = 'hello'
}

// Foo and Bar are forced on the constructor
class C implements static Foo, static Bar {
    static f = 42
    static b = 'hello'
}

To me, that's as clear as day.

their point is to improve the syntax of working with "one constructor and one prototype."

OK, I definitely just assumed what "their point" is and have no particular information to back it up. If thinking of JS new-ables as having two distinct pieces is the right mental model, then so be it.

You can't do it like this:

let widgetLike: Widget = {} let widgetConstructorLike: Widget = {}

See, to my eye that seems entirely obvious -- of course you can't just call them both Widget. I think we're arguing over a pretty minor point-of-view issue. I see a single dual-nature declaration as more natural, because classes are already dual-nature, and you see two single-purpose declarations as more natural because that allows for more flexible use.

That's what led me to the suggestion I made in my second comment this morning, addressed to Ryan, about how it would be nice to have a "magic" StaticShape conditional type. But as I say, I don't know how to make it, or if it's currently possible, or if it's even feasible to eventually make possible. And it would certainly be more complex for this use case (i.e. referencing the shape of the constructor function) than simply using two different interfaces in the first place.

OK, I definitely just assumed what "their point" is and have no particular information to back it up.

You should try to avoid that.

I see a single dual-nature declaration as more natural, because classes are already dual-nature

Yes, but classes are dual-nature because their sole purpose is to ease the developer in defining the constructor interface and the interface of the object it creates. TypeScript isn't interested in describing classes (as it should), it's interested in describing those two interfaces. This makes sense because classes are just a syntax tool that allows the developer to define everything in one code block, while the constructor and the instance contain the actual logic.

you see two single-purpose declarations as more natural because that allows for more flexible use

We can just agree to disagree, but the TS developers have to make a choice. Flexibility means handling more use cases, which in turn means TypeScript is more useful. Isn't this more valuable than saving a couple lines of code that never make it to production anyway?

I came up with two more use cases that also cover generics and inheritance and revealed some more open questions. I was trying to encode some of Haskells monad-instances, to see how typesafe I can get them with TypeScript.

Just for reference, here is a simplified version of Haskells monad type-class:

~haskell
class Monad m where
pure :: a -> m a
bind :: m a -> (a -> m b) -> m b
~

Naively, I started with:

~ts
interface Monad {
pure (x: a) : Monad

bind (f: (x:a) => Monad) : Monad
}
~

The pure-method is similar to a constructor; it should be static. So, I tried to split the interface into its static and dynamic parts:

~ts
interface Monad
{
bind (f: (x:a) => Monad) : Monad
}
interface MonadStatic
{
pure (x: a) : Monad

}
~

Next, I tried to implement the Identity-monad.

~~~ts
class Identity
implements Monad {
private value : a

constructor (value: a) {
this.value = value
}

static pure (x: a) : Monad{
return new Identity(x)
}

bind (f: (x: a) => Identity) : Identity {
return f(this.value)
}
}
~~~

What's still missing is the static interface instantiation. However, the trick from the handbook does not work here, because MonadStatic<a> is generic in a:

~ts
const Identity
:MonadStatic = class implements Monad { /.../ }
~

So here we are. With the syntax proposed above, I think it should be possible to fully instantiate the identity-monad:

~ts
class Identity implements Monad, static MonadStatic { /.../ }
~

However, the signature of pure in the interface and implementation is still different. This is because the type-variable a is bound differently: once by the interface-declaration and once by the method-implementation.


Next, I tried to encode the Maybe-monad. The challenge here is that a Maybe offers two constructors. I implemented these as two classes. This made sense because the bind-method also behaves differently on either construction. The pure-method, on the other hand, belongs to neither of those classes. I moved it into a common parent-class. Let's fast-forward to the final implementation:

~~~ts
abstract class Maybe
implements static MonadStatic {

static pure(x: a) : Maybe {
return new Just(x)
}

}

class Nothing extends Maybe implements Monad {
constructor () {
super()
}

public bind (f : (x : a) => Maybe) : Nothing {
return new Nothing()
}
}

class Just extends Maybe implements Monad {
private value : a

constructor (value : a) {
super()
this.value = value
}

public bind(f : (x : a) => Maybe) : Maybe {
return f(this.value)
}
}

~~~

I think the separation of static and dynamic interfaces resolves rather elegant in this example.

However, that raises the question if child-class-constructors should inherit the properties of their parent's constructors. In my example, exposing Just.pure as a public interface would be undesired.

@vzaidman's suggestion seems to be the only working workaround. However, it will only work in scenarios such as this one: the static field does not depend on the type of Props or State and it's optional.

Having an equivalent of implements applied to static fields would come in handy in other scenarios:

The static field is generic

The getDerivedStateFromProps React used by React is static, but it does need the type arguments provided when MyComponent is defined.

class MyComponent extends React.Component<Props, State> {
  static getDerivedStateFromProps(nextProps: Readonly<Props>, prevState: State): Partial<State> | null { /* ... */ }
}

Currently, getDerivedStateFromProps can be of any type. This is prone to human errors.

The static field is mandatory

Imagine a server-side rendering architecture in which a component tells the server how its data dependencies should be obtained by using a static method.

import { getInitialData, withInitialData } from "another-react-library";

class Page extends React.Component<Props, State> {
  static async getInitialData(props: Props) { /* ... */ }
}

export default withInitialData(Page);

If we wanted to make the getInitialData field mandatory so that each component can be server-side rendered, there is no way to achieve that right now. I could imagine having a class that requires it.

interface SSR<P, D> {
  getInitialData(props: P): Promise<D>
}

class UniversalComponent<P, S, D> extends React.Component<P, S> implements static SSR<P, D> { /* ... */ }

Such a component would be forced to define what it needs in order to be server-side rendered.

class Page extends UniversalComponent<Props, State, Dependencies> {
  // Required now!
  async static getInitialData(props: Props): Promise<Dependencies> { /* ... */ }
}

Here's my two cents:

I wish I could add static methods to my interfaces. The interfaces describe the API of an object. At the moment, that seems to be an instantiated class, however, when writing the interfaces and classes, we actually implement interfaces on classes, which are more akin to a messy mix of constructor and prorotype. A) Being able to write static methods inside a class alone is proof of that we cannot separate the two when working with class sugar.

From the docs:

In TypeScript, interfaces fill the role of naming these types, and are a powerful way of defining contracts within your code as well as contracts with code outside of your project.

B) I think that means that an interface is the description of the capabilities a class provides, which may include static methods - which we cannot declare with today's interfaces.

So, if we use A) and B) as a line of thinking, there's a mismatch. Which is why I would like to suggest a re-alignment between what classes provide and what interfaces provide - in the most intuitive way. Just let an interface look like a class declaration, with the class being the definition. It's something which was possible way back in other languages (C++) as well, and helps have a clear contract.

interface IA {
  static x: string
  n: number

  static foo(): void
  bar(): void
}

class A implements IA {
  static x: string = 'Hello';
  n: number = 42;

  static foo(): void {}
  bar(): void {}
}

Also, let's not forget that it is valid code to pass a constructor as parameter, which allows us to do quite a bit of useful stuff and might benefits a lot from having an interface with static declarations. That's also something which has to be included in such a contract.

function foo<T extends Object>(obj: { new(): T }, ...args: any[]) {
  const instance: T = new (obj.prototype.constructor.bind(obj, ...Array.from(arguments).slice(1)))();
  const constructor = instance.constructor;
  const typeName = constructor.name;

  // ...
}

There were some doubts about this suggestion, so let me address them:

// arg has... no members?

Yes, if there are no non-static methods declared then arg has no members. If that situation makes sense should, however, not be part of a discussion about a language's capabilities, since it leads to exactly this kind of highly hypothetical situation, which cannot be assessed at a language level. Maybe it does make sense for the logic? Maybe it increases the developer experience? No one can make any good argument without a concrete case, and then we are talking about a concrete case instead of the language, so let's not talk about it here.

// How do I get to ctor(arg).x ?

arg.constructor.x, which is valid code, and I needed it in several projects, which heavily make use of the type system to create a sound library usage (take a look at this project, for example)

// Is this a legal call?
// There doesn't seem to be any reason to reject it,
// since 'fn' can't illegally access anything that doesn't exist
fn({ });

No, it's not a legal call. {} does not implement the interface, so the type checker should reject it. fn cannot access arg.constructor.x, which is valid JS and valid TS, so there would be an obvious bug in TS if the above was allowed.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

manekinekko picture manekinekko  Â·  3Comments

uber5001 picture uber5001  Â·  3Comments

seanzer picture seanzer  Â·  3Comments

MartynasZilinskas picture MartynasZilinskas  Â·  3Comments

kyasbal-1994 picture kyasbal-1994  Â·  3Comments