TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Suggestion: Reopen static and instance side of classes

Open rbuckton opened this issue 10 years ago • 16 comments

Summary

To support both the semantics of subclassing built-ins in ES6 and still allow authors to augment built-ins, we need a mechanism to reopen the static and instance sides of a class.

Current state

Today we can re-open interfaces, allowing authors to augment built-ins (for example, to support polyfills):

// in lib.d.ts
interface Array<T> { /*...*/ }
interface ArrayConstructor { /*...*/ }
declare var Array: ArrayConstructor;

// in polyfill.ts
interface Array<T> {
  includes(value: T): Boolean;
}
interface ArrayConstructor {
  of<T>(...items: T[]): Array<T>;
}

Array.prototype.includes = function (value: any) { return this.indexOf(value) != -1; }
Array.of = function<T> (...items: T[]) { return items; }

We can also re-open the static side of a class, in a limited fashion:

// initial declaration
class MyClass {
}

// re-open
module MyClass {
  export var staticProperty = 1;
}

There are several issues with these approaches:

  • You cannot use type defined by the var/interface pattern in the extends clause of a class in TypeScript, meaning that "classes" defined using this pattern cannot be subclassed in ES6, which is an issue for built-ins.
  • While you can re-open the static side of a class using module, you can only use non-keyword identifiers for property names. So you could not, for example, add a [Symbol.species] property to the class, or use decorators on these members.
  • There is no way to re-open the instance side of a class.

Proposal

I propose we add a new syntactic modifier for the class declaration that would indicate we are re-opening an existing class. For this example I am using the keyword partial, although the semantics here differ significantly than the same-named capability in C#:

// in lib.d.ts
declare class Array<T> {
}

// in polyfill.ts
partial class Array<T> {
  static of<T>(...items: T[]) { return items; }
  includes(value: T): boolean { return this.indexOf(value) != -1; }
}

// emit (ES5)
Array.of = function() { 
  var items = [];
  for (var _i = 0; i < arguments.length; i++) 
    items[i] = arguments[i];
  return items;
}
Array.prototype.includes = function(value) {
  return this.indexOf(value) != -1;
}

Rules

  • A partial class declaration must be preceded by a non-partial class declaration in the same lexical scope. These should be the same rules that apply when merging a module with a class or function today.
  • A partial class declaration must have the same module visibility as the preceding non-partial class declaration.
  • A partial class declaration must have the same generic type parameters (including constraints) as the non-partial class declaration.
  • A partial class declaration cannot have an extends clause, but may have an implements clause.
  • A partial class declaration cannot have a constructor member.
  • A partial class declaration cannot have members with the same name as existing members on a class.
    • Exception: ambient partial class declaration members can merge with other ambient partial class declaration members if they are compatible overloads, similar to interfaces.
  • Non-static property declarations on a partial class declaration cannot have initializers.
  • A partial class declaration can have a class decorator. User code that executes in-between the initial class declaration and the partial declaration will be able to observe the class before decorators on the partial class are applied.
    • NOTE: We could choose to disallow class decorators on a partial class.

Out of scope

  • This proposal does not cover the case where built-in "classes" can often also be called as functions. This case is covered in #2959.
  • This proposal does not cover the case where authors may have already extended the interface of a built in. This case is covered in #2961.

Previous discussions

This has also been discussed previously:

  • #9 - Suggestion: Extension methods
  • #563 - Partial classes
  • https://typescript.codeplex.com/workitem/100

rbuckton avatar Apr 29 '15 18:04 rbuckton

Note comments in #563

danquirk avatar Apr 29 '15 18:04 danquirk

Also worth linking #9 since it covers basically the same use cases

RyanCavanaugh avatar Apr 29 '15 18:04 RyanCavanaugh

yes, please!

kode4food avatar Aug 05 '15 14:08 kode4food

Need to write up the status of this one

RyanCavanaugh avatar Apr 20 '16 20:04 RyanCavanaugh

I like this suggestion, but would like to see it extended slightly.

Say I have a class generated from a C# class. I would like to be able to apply decorators to properties of the class without having to change the generated code.

I could see this working, if I am allowed to add an existing property to the partial class with the same type or a more general compatible type (so any would work in all cases). The property would not change type.

Example:

// in Person.ts (a generated file from somewhere)
export class Person {
    @key
    id: number;
    name: string;
}

// in PersonExtensions.ts
import { Person } from './Person';

partial class Person {
    @required
    name: any;
}

MortenHoustonLudvigsen avatar Jul 13 '16 15:07 MortenHoustonLudvigsen

+1

manast avatar Aug 02 '16 17:08 manast

In JS you do this by extending the prototype. While a friendlier syntax would be welcome, supporting the traditional style would be great for those who need it. TS is rumored to be a superset of JS after all 🙂

// [ts] Property 'foo' does not exist on type 'Klass'.
Klass.prototype.foo = function() { console.log("foo!"); }
new Klass().foo();

qwertie avatar Jun 20 '18 22:06 qwertie

I would like this, because for example, if a 3rd-party type definition is not accurate, I would like to augment the class in order to quickly fix it for my case.

In my case, the constructor of a class is stated to receive an argument of one type, but in reality it can accept an argument of a union of two types, and I'd like to simply fix this without having to fork a library or rewrite the entire definition of the class.

trusktr avatar Oct 30 '18 02:10 trusktr

Is there any progression on this topic? I have the same problem as @trusktr .

JohnArcher avatar Apr 04 '19 11:04 JohnArcher

Random bikeshed as the original partial class proposal violates the design goal:

declare class Foo {};
interface class Foo {
  staticMethod(): boolean;
};

let result = Foo.staticMethod(); // boolean

saschanaz avatar Nov 24 '20 23:11 saschanaz

Actually this already works:

declare class Foo {
    static abc(): void ;
}

declare namespace Foo {
    export function bcd(): void;
}

Foo.abc();
Foo.bcd();

So maybe this should just be closed.

saschanaz avatar Dec 21 '20 21:12 saschanaz

I think this issue is also tracking adding new overloads to the construct signature, which can't be done with namespace... (although proposal in the suggestion OP does not seem to allow that either). So this should probably stay open.

jcalz avatar Sep 17 '21 15:09 jcalz

This idea would also be useful for extending builtins with private fields (to emulate internal slots) to prevent structural typing in builtins. e.g.:

declare partial class ArrayBuffer {
    #arrayBufferData: unknown;
}

const arrayBufferLike = { 
    byteLength: 200,
    slice: (begin: number, end?: number) => new ArrayBuffer(20),
};
// Would now report an error as this doesn't actually work in practice
const dataView = new DataView(arrayBufferLike);

Jamesernator avatar Jan 23 '24 00:01 Jamesernator

So another problem today is not all interfaces even have BlahConstructor interfaces to actually extend. For example AbortSignal is defined inline like:

declare var AbortSignal: {
    prototype: AbortSignal;
    new(): AbortSignal;
    /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_static) */
    abort(reason?: any): AbortSignal;
    /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/timeout_static) */
    timeout(milliseconds: number): AbortSignal;
};

which makes it impossible (without patching TypeScript) to add missing static methods in declarations as there is no var merging:

// Subsequent variable declarations must have the same type.  Variable 'AbortSignal' must be of type '{ new (): AbortSignal; prototype: AbortSignal; abort(reason?: any): AbortSignal; timeout(milliseconds: number): AbortSignal; }', but here has type '{ any(signals: readonly AbortSignal[]): AbortSignal; }'.ts(2403)
// lib.dom.d.ts(2337, 13): 'AbortSignal' was also declared here.
declare global {
    var AbortSignal: {
        any(signals: ReadonlyArray<AbortSignal>): AbortSignal;
    };
}

If everything were simply defined as partial class it'd be trivial:

declare global {
    partial class AbortSignal {
        static any(signals: ReadonlyArray<AbortSignal>): AbortSignal;
    }
}

Jamesernator avatar May 08 '24 10:05 Jamesernator

This was seemingly possible for a while due to what amounted to a "bug" that was patched in TS 5.5. class merging is technically not supported but did work for at least the last 2 years, and did allow you to merge static methods.

With this "bug" patched, it seems like now would be an excellent time to re-evaluate this. I imagine this has gone unlooked at for so long because it simply worked already.

Example use from discord.js: https://github.com/discordjs/discord.js/blob/ba0cb66ff92b0c46b020a2e471501aa4432bc978/packages/discord.js/typings/index.d.ts#L246-L258 Unfortunately, the EventEmitter class doesn't have a separated EventEmitterConstructor like some of the other base classes, so we can't use namespace merging. Luckily for this use case, generics were recently added to EventEmitter in @types/node, but the static methods were still not done. I assume this was just overlooked and we can completely resolve our use case with another PR there. However, I believe this still stands as a great example as to why this is necessary.

ckohen avatar Jun 24 '24 10:06 ckohen

Is there a future where this behaviour can be implemented in ambient declarations only? Or at least, allowing for static/constructor overloads in ambient declarations

I'd definitely be happy to give a shot at implementing this

This would unblock the PR https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1941, which won't merge at the moment because the dom lib generator eventually plans to migrate to class declarations, and the current state of the PR uses the interface/var pattern which would block migration to classes.

alii avatar Mar 16 '25 14:03 alii