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 non-polymorphic ("private") extension of base classes #57979

Open
6 tasks done
dmchurch opened this issue Mar 28, 2024 · 5 comments
Open
6 tasks done

Allow non-polymorphic ("private") extension of base classes #57979

dmchurch opened this issue Mar 28, 2024 · 5 comments
Labels
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

Comments

@dmchurch
Copy link

πŸ” Search Terms

private non-polymorphic non-assignable inheritance extension subclass change method signature of base class

βœ… Viability Checklist

⭐ Suggestion

I would like to write an ES6 class that does not inherit the typing of its base class (and thus doesn't expose any properties or methods defined in the base class, unless they are overridden in the subclass). In C++ I would call this "private inheritance", but in short: I would like to be able to use the implementation of a class (by using the super keyword in a method) without declaring my instances to be assignment-compatible.

πŸ“ƒ Motivating Example

To the best of my knowledge, there are currently no ways to express this in TypeScript. In pure JavaScript, I can write the following:

export class MyTuple extends Array {
  constructor(...args) {
    super();
    this.push(...args);
    Object.freeze(this);
  }
}

Instances of the MyTuple class, despite extending the Array prototype, don't conform to the Array contract, as all of the mutation methods will throw a TypeError. Even worse, direct array element assignment will silently fail, as assignment to a read-only property is simply ignored. However, the only way to get TypeScript to output this JS code is to use a TypeScript class declaration, and these always propagate the base class typings.

In this particular case, the class is a better match for the ReadonlyArray interface, so ideally I could write the following in TypeScript:

export class MyTuple<T> extends private Array<T> implements ReadonlyArray<T> {
  constructor(...args: readonly T[]) {
    super();
    this.push(...args);
    Object.freeze(this);
  }
}

πŸ’» Use Cases

  1. What do you want to use this for?
    In my current project, I'm writing a variable-dimensionality geometry library in which the Point class is a specialization of Array<number>. Point is a generic class with the signature Point<Dims extends number>, so a 2D point is a Point<2>, a 3D point is a Point<3>, etc. Using Array as the base class provides a number of advantages, like being able to use .map() in order to create a new Point with the same dimensionality, based on the existing. However, most of the Array functionality shouldn't be exposed to consumers; in particular, anything that modifies the length of the array or creates an array of different length (push, slice, etc) would cause improper typing.
  2. What shortcomings exist with current approaches?
    Well, first off, the return type of .map() is incorrect because of Array inheritance in ES6 declaration file (lib.es6.d.ts)Β #10886; it and all the other copy-instantiating methods should return a Point but instead return number[]. I can't use declare to override the method signature directly because of Provide declare keyword support for class methodsΒ #38008 and Allow 'declare' on methods and constructorΒ #48290, and TS won't allow me to override it as a property because the types don't match.
  3. What workarounds are you using in the meantime?
    I'm removing the typing entirely by casting Array to a dummy constructor of Object:
    export class Point<Dims extends number = 2> extends (Array as new () => Object) implements PointLike<Dims> {
    Previously I'd been casting it to a constructor of readonly number[], but that didn't work because, as mentioned above, I need to declare map() as a method that returns a Point, and the existing number[] return signature can't be cast to a Point derived from readonly number[]. So now I'm casting it to Object, which comes with a fairly serious drawback: I can no longer use the super keyword in my methods, because TS now thinks that super is type Object, and I can't even correct it - the super keyword can't have a type assertion applied to it, as mentioned in Allow type assertion for "super" keyword in method.Β #41034.

Now I go to write a lot of type assertions and // @ts-expect-error comments, because so far as I can tell, there is no other way around this.

@dmchurch
Copy link
Author

This is also possibly related to #4628. Not in the sense that my issue is affected by static inheritance, but because if this issue were resolved by the addition of something like the extends private mechanism described here, that could also be used to resolve 4628, by allowing decoupling of inherited members vs exposed members.

@dmchurch
Copy link
Author

It occurs to me that one radical way to solve this problem would be to add some syntax that allows you to suppress creating the interface portion of the class altogether. Then you could simply define the interface separately with whatever subset of the actual functionality you want to expose, and then the ES6 class definition just gets matched against that interface, rather than generating the interface itself.

...Huh. This actually feels like a useful and actionable feature. You could rewrite the original example like this:

// This line is the only definition of the MyTuple<T> type
export interface MyTuple<T> extends ReadonlyArray<T> { }
// A private class is one whose types are not visible to the outside. It has signature { } or possibly object.
export private class MyTuple<T> extends Array<T>
// This one implements the MyTuple<T> interface to ensure that it matches its public API
    implements MyTuple<T> {
  constructor(...args: readonly T[]) {
    super();
    this.push(...args);
    Object.freeze(this);
  }
}

What do you think?

@whzx5byb
Copy link

@dmchurch You can use a class expression to make it "private".

// This line is the only definition of the MyTuple<T> type
export interface MyTuple<T> extends ReadonlyArray<T> { }
// A private class is one whose types are not visible to the outside. It has signature { } or possibly object.
export const MyTuple = class<T> extends Array<T>
// This one implements the MyTuple<T> interface to ensure that it matches its public API
    implements MyTuple<T> {
  constructor(...args: readonly T[]) {
    super();
    this.push(...args);
    Object.freeze(this);
  }
}

Note that you MUST NOT use the name MyTuple for the class expression. Use any other name or anonymous instead, otherwise the interface MyTuple<T> declared in the outer scope will be shadowed.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Apr 11, 2024
@dmchurch
Copy link
Author

@whzx5byb Yes, I've used that workaround before, and it's not applicable in this case. I need the emit to contain an ECMAScript top-level class export, which has slightly different semantics than an anonymous class assigned to a const (or a let, or a var, etc etc). If you have any suggestions that actually fit my needs, I'm all ears.

@n1kk
Copy link

n1kk commented Apr 23, 2024

I was looking for something similar, I wanted my subclass to be a readonly array. After playing with all the possibilities I managed frankenstein this together from the bits an pieces on StackOverflow and this repos issues.

function ReadonlyArrayCtor(): { new <T>(...items: readonly T[]): ReadonlyArray<T> } {
    return Array as any;
}

export class MyTuple<T> extends ReadonlyArrayCtor()<T> {
    constructor(...args: readonly T[]) {
        super(...args);
        Object.freeze(this);
    }
}

let vec = new MyTuple(1, 2);

vec[1] = 100; // error

Playground

Kinda works. But couldn't make it an actual tuple though. Even casting the constructor factory to readonly [number, number] did not limit the subclass to only 2 indexes. I guess extends broadens the type to have "at least two".

function ReadonlyArrayCtor(): { new (x: number, y: number): readonly [number, number] } {
    return Array as any;
}

export class MyTuple<T> extends ReadonlyArrayCtor() {
    constructor(x: number, y: number) {
        super(x, y);
        Object.freeze(this);
    }
}

let vec = new MyTuple(1, 2);

let x = vec[0]
let y = vec[1]
let z = vec[2] // no error

vec[1] = 100; // error

Playground

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
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
Projects
None yet
Development

No branches or pull requests

4 participants