1

I've seen questions that are somewhat similar to this but don't answer the specific question I have. I want to create a base class that users should be able to extend in such a way that the methods in my base class that return instances of the base class, return instances of the derived class when called from the derived class. This class is intended to be immutable, with a bunch of chainable methods. Here is a class just to illustrate the problem:

class Pair<T> {
    constructor(public left: T, public right : T) {}

    setLeft(left: T) {
        return new Pair(left, this.right)
    }

    setRight(right: T) {
        return new Pair(this.left, right)
    }

    map<R>(fun: (t: T) => R) {
        return new Pair(fun(this.left), fun(this.right));
    }
}

A simple way to extend it would be to just extend pair's prototype, but I don't think this can be done in another file (when I do interface Pair<T> { } I get an error telling me "Import declaration conflicts with local declaration of Pair".

Another option is to create a derived class:

class MyPair<T> extends Pair<T> {
    constructor(left: T, right: T) {
        super(left, right)
    }

    swap() {
        return new MyPair(this.right, this.left)
    }
}

The problem here is that as soon as I call a Pair method, I won't be able to call any MyPair method since I'll get a Pair (doing new MyPair(10, 20).setLeft(30).swap() is not possible).

This suggestion shows how I could use an activator function to create instances of the derived class, something like this:

class Pair<T, R extends Pair<T, R>> {
    constructor(private activator: (left: T, right: T) => R, public left: T, public right : T) {}

    setLeft(left: T) {
        return this.activator(left, this.right)
    }

    setRight(right: T) {
        return this.activator(this.left, right)
    }
}

That seems to kind of work, but something seems wrong with the fact that activator uses type T for the arguments. These two lines give an error when I use them in a method inside Pair:

var hi = new Pair(this.activator, 10, 20);
var bye = this.activator(10, 20);

The error says that number is not assignable to type T, so it seems the type of the arguments are bound to T. I would need a way for my generic activator to stay generic, but that doesn't seem possible. If I define this:

function foo<T>(fun: (t: T) => T): (t: T) => T {
    return fun
}

And then I do this

var bar = foo(a => a)

Then the type of bar will be (t: unknown) => unknown instead of (a: T) => T. So I guess I just hit another dead end? If so, is there any other alternative?

Juan
  • 15,274
  • 23
  • 105
  • 187

1 Answers1

2

What about using the constructor property?

class Pair<T> {
    ['constructor']: new (...args: ConstructorParameters<typeof Pair>) => this

    constructor(public left: T, public right: T) {}

    setLeft(left: T): this {
        return new this.constructor(left, this.right)
    }

    setRight(right: T): this {
        return new this.constructor(this.left, right)
    }
}

class MyPair<T> extends Pair<T> {
    constructor(left: T, right: T) {
        super(left, right)
    }

    swap() {
        return new MyPair(this.right, this.left)
    }
}

new MyPair(1, 2)
    .setLeft(3)
    .swap()

The only issue with this is it doesn't stop you from declaring a class with a constructor incompatible with (left: T, right: T) => this (e.g. extra arguments are required.)

Playground link


An alternative method would be to use the Symbol.species static property, which would allow subclasses to customise the 'constructor' used by Pair, but would mean every subclass would have to set Symbol.species accordingly:

interface PairConstructor<T> {
    new (...args: ConstructorParameters<typeof Pair>): T
    [Symbol.species]: PairConstructor<T>
}

class Pair<T> {
    ['constructor']: PairConstructor<this>
    static [Symbol.species] = Pair

    constructor(public left: T, public right: T) {}

    setLeft(left: T): this {
        return new this.constructor[Symbol.species](left, this.right)
    }

    setRight(right: T): this {
        return new this.constructor[Symbol.species](this.left, right)
    }
}

class MyPair<T> extends Pair<T> {
    static [Symbol.species] = MyPair

    constructor(left: T, right: T) {
        super(left, right)
    }

    swap() {
        return new MyPair(this.right, this.left)
    }
}

Playground link

Lauren Yim
  • 12,700
  • 2
  • 32
  • 59
  • Please see my edited question. In my actual class I will have a method like the `map` one I just added to `Pair`, so having `this` as the return type of every method wouldn't work in those cases (something like `this` would work if it was possible). But I feel like your second example gets close. I'll try it out see if I come up with anything. – Juan Aug 05 '21 at 22:33
  • 1
    @Juan That sounds like a perfect fit for [higher-kinded types](https://github.com/microsoft/TypeScript/issues/1213). However, that isn't possible in TypeScript yet. [fp-ts](https://gcanti.github.io/fp-ts/) sort of emulates this though using module augmentation; you might want to check that out. – Lauren Yim Aug 05 '21 at 23:33