Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow specifying interface implements clauses for the static side of classes #33892

Open
5 tasks done
hdodov opened this issue Oct 9, 2019 · 56 comments
Open
5 tasks done
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@hdodov
Copy link

hdodov commented Oct 9, 2019

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:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.
@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript labels Oct 9, 2019
@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus and removed Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Oct 15, 2019
@RyanCavanaugh RyanCavanaugh added this to Next Meeting in Suggestion Backlog Triage Oct 15, 2019
@RyanCavanaugh
Copy link
Member

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 RyanCavanaugh changed the title Syntax for specifying class static side interface without an expression Allow specifying interface implements clauses for the static side of classes Oct 15, 2019
@hdodov
Copy link
Author

hdodov commented Oct 16, 2019

@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.

@RyanCavanaugh
Copy link
Member

: 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

@hdodov
Copy link
Author

hdodov commented Oct 17, 2019

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?

@thw0rted
Copy link

thw0rted commented Oct 17, 2019

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:

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.

@hdodov
Copy link
Author

hdodov commented Oct 17, 2019

@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.

@thw0rted
Copy link

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".

@hdodov
Copy link
Author

hdodov commented Oct 17, 2019

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.

@RyanCavanaugh
Copy link
Member

@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.

@thw0rted
Copy link

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?

@thw0rted
Copy link

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<T> = ✨ ; // magic!

type WidgetConstructor = StaticShape<Widget>; // === 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.

@hdodov
Copy link
Author

hdodov commented Oct 18, 2019

@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.

@thw0rted
Copy link

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.

@hdodov
Copy link
Author

hdodov commented Oct 18, 2019

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?

@RaoulSchaffranek
Copy link

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:

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

Naively, I started with:

interface Monad<a> {
  pure (x: a) : Monad<a>
  bind (f: (x:a) => Monad<b>) : Monad<b>
}

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:

interface Monad<a> {
  bind (f: (x:a) => Monad<b>) : Monad<b>
}
interface MonadStatic<a> {
  pure (x: a) : Monad<a>
}

Next, I tried to implement the Identity-monad.

class Identity<a> implements Monad<a> {
  private value : a

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

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

  bind (f: (x: a) => Identity<b>) : Identity<b> {
    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:

const Identity<a>:MonadStatic<a> = class implements Monad<a> { /*...*/ }

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

class Identity<a> implements Monad<a>, static MonadStatic<a> { /*...*/ } 

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:

abstract class Maybe<a> implements static MonadStatic<a> {
  
  static pure<a>(x: a) : Maybe<a> {
    return new Just(x)
  }

}

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

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

class Just<a> extends Maybe<a> implements Monad<a> {
  private value : a

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

  public bind(f : (x : a) => Maybe<b>) : Maybe<b> {
    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
Copy link

declare namespace React {
  namespace Component {
    const whyDidYouRender: WhyDidYouRender.WhyDidYouRenderComponentMember;
  }
}

worked for me somehow.
see: https://github.com/welldone-software/why-did-you-render/blob/6a85ed215279840d0eedbea5d86eba92cfb1291b/types.d.ts

@karol-majewski
Copy link

@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> { /* ... */ }
}

@minecrawler
Copy link

So.... close the issue I guess? 🎉

Sounds like a nice workaround, I really want to play around with that now! However it looks like just that: a workaround. Not static declarations in an interface, as requested in the OP :(

@thw0rted
Copy link

thw0rted commented Mar 7, 2023

The OP isn't actually about interfaces with static members, it's about assigning (the static side of) a class to a variable that's typed with an interface. That's pretty much exactly what satisfies gives you, from my example. It would be nice to be able to combine it on one line (class Child extends Parent implements SomeInterface satisfies StaticThing { ... }), but the current two-line version gets the job done.

@1EDExg0ffyXfTEqdIUAYNZGnCeajIxMWd2vaQeP

since expr satisfies Type is an expression that evaluates to expr, the following also works, but looks a little strange.

const A = class {
    static f(): number {
        return 1;
    }
} satisfies HasF;

@hdodov
Copy link
Author

hdodov commented Mar 8, 2023

@1EDExg0ffyXfTEqdIUAYNZGnCeajIxMWd2vaQeP (very unwieldy username) note that this requires you to store the class in a variable. As I've said in my OP, this is already possible:

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

The issue is about making this possible without unnecessary variable assignments. Your workaround, as you've said, is in fact a little strange, and the whole idea is to make this more intuitive and approachable, which it currently is not.

@thw0rted
Copy link

thw0rted commented Mar 8, 2023

I just realized that my "working" example above leaves you with additional statements in the JS emit. Look at the output on that Playground link, you have "A;" and "B;" at the end. That also means you'll trigger "no standalone statements" linter rules.

Maybe we can just make class A {...} satisfies Foo legal and call it a day?

@minecrawler
Copy link

Maybe we can just make class A {...} satisfies Foo legal and call it a day?

It already is legal; see @1EDExg0ffyXfTEqdIUAYNZGnCeajIxMWd2vaQeP 's variant.

interface IAStatic {
    f(): number;
}

interface IA {
    a(): void;
}

const A = class implements IA {
    static f(): number {
        return 1;
    }

    a() {}
} satisfies IAStatic;

I played around with it. I'm not 100% happy with having two interfaces (one for static, one for instance), but being able to add proper types in a relatively simple manner is already very good.

@thw0rted
Copy link

thw0rted commented Mar 8, 2023

Doesn't const A = class { ... } have different type-space meaning than class A { ... }? I learned to always prefer named classes over const assignments in this case, and I thought it was for a good reason -- though I couldn't tell you exactly why if you put me on the spot.

@minecrawler
Copy link

Unfortunately, I'm not an expert on something low-level theoretical like that. My guess would be that one is a class (constructor) and the other one is a reference to a class (constructor). From my experience, they work interchangeably in practice.

To be honest, when writing code, I don't care that much about such things... I'm more worried about contracts and APIs being well typed and architected, which is why missing ways to put statics into an binding contract interfaces (as a trivial way to define the contracts) has always been a problem for me.

For the fun of it, I put the code into Babel and had it be transpiled to ES5 (in order to strip the class sugar). Both versions (assigning to a variable and defining the name directly) produce the same code (assign to a variable). However, if I had to manually write ES5 JS, I'd probably also make the difference:

// ES6+
const A = class {
    static f() {
        return 1;
    }
};

class B {
    static f() {
        return 1;
    }
}


// ES5
var A = function() {};
A.f = function() { return 1; };

function B() {}
B.f = function() { return 1; };

var is its own kind of beast (hoisting monster??), but iirc functions also used to be a hoisting mess. Today, we only use block-scoped declarations, so I don't think we would see any difference between A and B, even when writing

const A = function() {};
A.f = function() { return 1; };

function B() {}
B.f = function() { return 1; };

Correct me if I'm wrong, though!

@TheUnlocked
Copy link

Doesn't const A = class { ... } have different type-space meaning than class A { ... }? I learned to always prefer named classes over const assignments in this case, and I thought it was for a good reason -- though I couldn't tell you exactly why if you put me on the spot.

const A = class { ... } will have A.name === '' rather than A.name === 'A' as intended. That can have consequences when debugging (e.g. React likes to leverage function and class names for their dev tools and when making error messages), so you would need to repeat the name for both the variable and the class expression.

@yume-chan
Copy link
Contributor

I think #32452 is a better solution for this issue. With that implemented this should automatically type checks:

interface I {
    constructor: { fn(): void; }
}

class C implements I {
    static fn(): void { }
}

It doesn't require any new syntax, and works as real JavaScript does: (new C()).constructor.fn === C.fn.

Except it's unclear how to extend interfaces with static part, maybe it will need this:

interface IA {
    constructor: { a: number };
}

interface IB extends IA {
    constructor: IA["constructor"] & { b: string };
}

@yume-chan
Copy link
Contributor

Doesn't const A = class { ... } have different type-space meaning than class A { ... }?

class A declares both a value and a type (for the instances of A), var A = class {} only declares a value.

class A {}
let a: A;

const B = class {};
let b: B; // 'B' refers to a value, but is being used as a type here. Did you mean 'typeof B'
let bWorks: InstanceType<typeof B>;

Or you can

const B = class {};
type B = InstanceType<typeof B>;
let b: B;

I remember there is an issue for allowing any constructor-typed variables to act like a type, but I can't find it now.

@seansfkelley
Copy link

MyClass satisfies Statics; would be the preferred way of writing this.

...

So.... close the issue I guess? 🎉

While this workaround does seem to work well for ensuring type safety, the ergonomics aren't great so I would not call it done.

Aside from the nonobvious and unusual place of declaration, the after-class-definition requirement means it's impossible to get autocomplete like you do with non-static methods and properties since the type checking is completely disconnected from the declaration. It also means any type errors are (1) dumped onto the same single line, (2) very far away from what actually caused the error, especially if the class is long, and (3) nested at least one level deeper than necessary, since the error message always talks about the class itself failing to type check before mentioning which fields are at fault. Lastly, the intent is obscured, since one would reasonably expect a requirement of the class's statics to be declared along with other requirements of the class -- that is, the implements clause, or near it.

In short: yes, it gets the job done, but getting there is unpleasant.

lawrence-forooghian added a commit to ably/ably-js that referenced this issue May 8, 2023
haven't formatted this — will do once contents of class are correct

Use of `satisfies` comes from suggestion in [1].

[1] "Allow specifying interface implements clauses for the static side
of classes": microsoft/TypeScript#33892
lawrence-forooghian added a commit to ably/ably-js that referenced this issue May 8, 2023
haven't formatted this — will do once contents of class are correct

Use of `satisfies` comes from suggestion in [1].

[1] "Allow specifying interface implements clauses for the static side
of classes": microsoft/TypeScript#33892
lawrence-forooghian added a commit to ably/ably-js that referenced this issue May 9, 2023
haven't formatted this — will do once contents of class are correct

Use of `satisfies` comes from suggestion in [1].

[1] "Allow specifying interface implements clauses for the static side
of classes": microsoft/TypeScript#33892
lawrence-forooghian added a commit to ably/ably-js that referenced this issue May 9, 2023
haven't formatted this — will do once contents of class are correct

Use of `satisfies` comes from suggestion in [1].

[1] "Allow specifying interface implements clauses for the static side
of classes": microsoft/TypeScript#33892
lawrence-forooghian added a commit to ably/ably-js that referenced this issue May 10, 2023
haven't formatted this — will do once contents of class are correct

Use of `satisfies` comes from suggestion in [1].

[1] "Allow specifying interface implements clauses for the static side
of classes": microsoft/TypeScript#33892
@jthoward64
Copy link

MyClass satisfies Statics; would be the preferred way of writing this.
...
So.... close the issue I guess? tada

While this workaround does seem to work well for ensuring type safety, the ergonomics aren't great so I would not call it done.

Aside from the nonobvious and unusual place of declaration, the after-class-definition requirement means it's impossible to get autocomplete like you do with non-static methods and properties since the type checking is completely disconnected from the declaration. It also means any type errors are (1) dumped onto the same single line, (2) very far away from what actually caused the error, especially if the class is long, and (3) nested at least one level deeper than necessary, since the error message always talks about the class itself failing to type check before mentioning which fields are at fault. Lastly, the intent is obscured, since one would reasonably expect a requirement of the class's statics to be declared along with other requirements of the class -- that is, the implements clause, or near it.

In short: yes, it gets the job done, but getting there is unpleasant.

I think going from

const A = class implements IA {
    static f(): number {
        return 1;
    }

    a() {}
} satisfies IAStatic;

to

class A implements IA {
    static f(): number {
        return 1;
    }

    a() {}
} satisfies IAStatic;

would probably address most/all of the concerns in the issue while hopefully not adding much compiler overhead and making the information available to autocomplete. I haven't looked much into the implementation here, but it doesn't seem like that big of an adaptation, especially considering the improved ergonomics it allows.

@thw0rted
Copy link

I think if we have to update the compiler to support a new syntax anyway, class A extends B implements IA satisfies IAStatic {...} is more ergonomic than class A extends B implements IA { ... } satisfies IAStatic. Putting one of the three relationships after the declaration, likely several pages of code later, was already going to be worse. The only advantage is that it can be used as a workaround right now....

lawrence-forooghian added a commit to ably/ably-js that referenced this issue May 16, 2023
wip convert Node Crypto to class

haven't formatted this — will do once contents of class are correct

Use of `satisfies` comes from suggestion in [1].

[1] "Allow specifying interface implements clauses for the static side
of classes": microsoft/TypeScript#33892

wip convert node crypto CBCCipher to class

This concludes the conversion, started in f46e972, of the nodejs
platform’s crypto.js file to TypeScript.

Convert web’s crypto.ts to use classes

TODO need to format, but first document what I did
lawrence-forooghian added a commit to ably/ably-js that referenced this issue May 16, 2023
wip convert Node Crypto to class

haven't formatted this — will do once contents of class are correct

Use of `satisfies` comes from suggestion in [1].

[1] "Allow specifying interface implements clauses for the static side
of classes": microsoft/TypeScript#33892

wip convert node crypto CBCCipher to class

This concludes the conversion, started in 18ba2a0, of the nodejs
platform’s crypto.js file to TypeScript.

Convert web’s crypto.ts to use classes

TODO need to format, but first document what I did
lawrence-forooghian added a commit to ably/ably-js that referenced this issue May 16, 2023
wip convert Node Crypto to class

haven't formatted this — will do once contents of class are correct

Use of `satisfies` comes from suggestion in [1].

[1] "Allow specifying interface implements clauses for the static side
of classes": microsoft/TypeScript#33892

wip convert node crypto CBCCipher to class

This concludes the conversion, started in 18ba2a0, of the nodejs
platform’s crypto.js file to TypeScript.

Convert web’s crypto.ts to use classes

TODO need to format, but first document what I did
lawrence-forooghian added a commit to ably/ably-js that referenced this issue May 16, 2023
wip convert Node Crypto to class

haven't formatted this — will do once contents of class are correct

Use of `satisfies` comes from suggestion in [1].

[1] "Allow specifying interface implements clauses for the static side
of classes": microsoft/TypeScript#33892

wip convert node crypto CBCCipher to class

This concludes the conversion, started in 18ba2a0, of the nodejs
platform’s crypto.js file to TypeScript.

Convert web’s crypto.ts to use classes

TODO need to format, but first document what I did
lawrence-forooghian added a commit to ably/ably-js that referenced this issue May 25, 2023
It’s hard to tell what’s happened here due to all of the indentation
changes. It’s the following:

- remove the temporary underscored interfaces introduced in
  e4c4b9c, and integrate their contents into the class constructor and
  body

- convert prototype function properties to methods (removing the `this`
  argument)

- convert Crypto.CipherParams to a property

- add `satisfies` operator for compiler to validate that static methods
  of Crypto classes implement ICryptoStatic (approach suggested in [1])

[1] microsoft/TypeScript#33892
lawrence-forooghian added a commit to ably/ably-js that referenced this issue May 25, 2023
It’s hard to tell what’s happened here due to all of the indentation
changes. It’s the following:

- remove the temporary underscored interfaces introduced in
  b8a8f5d, and integrate their contents into the class constructor and
  body

- convert prototype function properties to methods (removing the `this`
  argument)

- convert Crypto.CipherParams to a property

- add `satisfies` operator for compiler to validate that static methods
  of Crypto classes implement ICryptoStatic (approach suggested in [1])

[1] microsoft/TypeScript#33892
lawrence-forooghian added a commit to ably/ably-js that referenced this issue May 25, 2023
It’s hard to tell what’s happened here due to all of the indentation
changes. It’s the following:

- remove the temporary underscored interfaces introduced in
  b8a8f5d, and integrate their contents into the class constructor and
  body

- convert prototype function properties to methods (removing the `this`
  argument)

- convert Crypto.CipherParams to a property

- add `satisfies` operator for compiler to validate that static methods
  of Crypto classes implement ICryptoStatic (approach suggested in [1])

[1] microsoft/TypeScript#33892
lawrence-forooghian added a commit to ably/ably-js that referenced this issue Jun 5, 2023
It’s hard to tell what’s happened here due to all of the indentation
changes. It’s the following:

- remove the temporary underscored interfaces introduced in
  3f5ec6f, and integrate their contents into the class constructor and
  body

- convert prototype function properties to methods (removing the `this`
  argument)

- convert Crypto.CipherParams to a property

- add `satisfies` operator for compiler to validate that static methods
  of Crypto classes implement ICryptoStatic (approach suggested in [1])

[1] microsoft/TypeScript#33892
@gylove1994

This comment was marked as outdated.

@gBasil
Copy link

gBasil commented Jan 14, 2024

There still seems to be a shortcoming is the current suggested workaround:

image

Would this be fixed by the syntax change (not requiring a variable) and is there a way to work around this for now, or am I making some sort of mistake and this isn't an issue?

Specifically, I ran into this issue while trying to solve the bigger issue of the type of Test being typeof (Anonymous class). That means that this code:

const aaa: typeof Test = Test.parse();

along with removing the : typeof Test from the static method, gives this error:

image

The following is a workaround, but it doesn't seem very clean and it would be nicer to not have to do this:

const aaa = Test.parse() as unknown as typeof Test;

The type of Test is still typeof (Anonymous class), however.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
Development

No branches or pull requests