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

Polymorphic/generic this as return type for subclasses #28

Merged
merged 4 commits into from
Nov 15, 2021

Conversation

f1ames
Copy link
Contributor

@f1ames f1ames commented Nov 5, 2021

I know this PR title sounds a little cryptic because well... it's not easy to explain but I will try.

The issue

The main issue this PR is trying to solve is as follows. Image we have a generic base class which methods can return new instances of itself:

class BaseClass<T> {
    public value: T;

    constructor(value: T) {
        this.value = value;
    }

    method1(value: T): BaseClass<T> {
        return new BaseClass<T>(value);
    }

    method2<U>(value: U): BaseClass<U> {
        return new BaseClass<U>(value);
    }
}

And extending class:

class DerivedClass<T> extends BaseClass<T> {
    test: () {
        console.log(this);
    }
}

So now any call to derivedClassInstance.methodX() should return DerivedClass instance. Unfortunately, with the code above it will always return BaseClass instance:

const bc = new BaseClass(1);
bc.method1(2); // returns BaseClass<number>
bc.method2("foo"); // returns BaseClass<string>

const dc = new DerivedClass(1);
dc.method1(2); // returns BaseClass<number> but should return DerivedClass<number>
dc.method2("foo"); // returns BaseClass<string> but should return DerivedClass<string>

Approaches

I spend some time looking into what we can do and how to handle this. I will talk a bit about some ideas.

Let's solve this with this

If you look back at the above code, BaseClass.method1() could be written as:

    method1(value: T): BaseClass<T> {
        this.value = value;
        return this;
    }

This partially solves the issue as calling dc.method1(2); will now return this (which is dc in runtime). However:

  • For methods changing generic type (like .method2() above) it's not really working.
  • Still TS will show (static code analysis / during compile time) that dc.method1() returns BaseClass<T> as method signature says.
  • It also means that TS will complain about dc.method1().test() (even though it works in the runtime).

Dynamic approach using TS utility functions

The idea was to somehow dynamically generate return type with TS ReturnType utility, something like:

    method1(value: T): ReturnType<() => typeof this.constructor> {
        return new BaseClass<T>(value);
    }

It doesn't have to be this.constructor it could be any property or method of the class we can fully control. However, using this in such context is prohibited by TS - Return type of public method from exported class has or is using private name 'this'. And AFAIU this basically make it impossible to use any construct with this (so any field, method, etc) to dynamically type methods return type. And this is the only thing we could rely on to deduce type here.

Static approach and some typing

Since TS is statically typed language I tired more static approach with a lot of typing (typing as on keyboard)... 😝 Anyway...

It requires methods overloading and duplicating them in subclasses. This is the approach which lead me the closest to a working solution.

The solution

From what I understand what we want to achieve is called "polymorphic generic this" or higher-kinded types (see 1, 2 and 3) 🤔 I'm not sure though 🤔

And as for the above I've found two (related) issues in TypeScript tracker which describes similar/same concepts - microsoft/TypeScript#6223 and microsoft/TypeScript#4967 (comment). AFAIU it's not really feasible to achieve this with clean and more dynamic approach.

There are in fact two issues to be solved - this and return type should be a correct class instance during runtime. And TypeScript compiler should also understand the code and correct return type (so it can compile the code without errors and generate correct TS typings). This are in fact two separate issues where one doesn't necessary solve the other.

This PR contains src/generic-this-1.ts file which shows the approach I have used on abstract classes. And so:

Returning correct class instance during runtime

Instead of creating new instance directly in "common" methods, let's extract create method like:

class BaseClass<T> {
    public value: T;

    constructor(value: T) {
        this.value = value;
    }

    create<U>(value: U): BaseClass<U> {
        return new BaseClass<U>(value);
    }

    method1<U>(value: U): BaseClass<U> {
        return this.create<U>(value);
    }
}

then in derived class we can simply override it so it creates instance of this class:

class DerivedClass<T> extends BaseClass<T> {
    create<U>(value: U): DerivedClass<U> {
        return new DerivedClass<U>(value);
    }
}

and now DerivedClass instance will be correctly returned:

const dc = new DerivedClass(123);
dc.method1(456); // returns DerivedClass<number> instance

Make TS understand what's going on

Still TS will complain that dc.method1(456); returns BaseClass<T> and so we need to override method1 like:

class DerivedClass<T> extends BaseClass<T> {
    create<U>(value: U): DerivedClass<U> {
        return new DerivedClass<U>(value);
    }

    method1<U>(value: U): DerivedClass<U> {
        return super.method1<U>(value) as DerivedClass<U>;
    }
}

And now both during compile time and runtime we have correct return type. It also generates correct TS typings.

Wait... there is more

What if we have derived class with "fixed" type:

class BaseClassFixedType extends BaseClass<string> {}

This requires a similar approach but since create and method1 will not be generic here, it needs correct overloading in BaseClass:

class BaseClass<T> {
    public value: T;

    constructor(value: T) {
        this.value = value;
    }

    create(value: T): BaseClass<T>;
    create<U>(value: U): BaseClass<U>;
    create<U>(value: U): BaseClass<U> {
        return new BaseClass<U>(value);
    }

    method1(value: T): BaseClass<T>;
    method1<U>(value: U): BaseClass<U>;
    method1<U>(value: U): BaseClass<U> {
        return this.create<U>(value);
    }
}

and then in derived class we use only non-generic version:

class BaseClassFixedType extends BaseClass<string> {
    create(value: string): BaseClassFixedType {
        return new BaseClassFixedType(value);
    }

    method1(value: string): BaseClassFixedType {
        return super.method1(value) as BaseClassFixedType;
    }
}

Now BaseClassFixedType instance will be correctly returned:

const bcft = new BaseClassFixedType("foo");
bcft.method1("bar); // returns BaseClassFixedType instance

Please see src/generic-this-1.ts file which contains the full code with sample calls too.

Wait... there is more

Kind of... 😅 The issue I found so far with the above approach is that fixed derived class can't use generic overloaded method version. So the below code will fail:

const bcft = new BaseClassFixedType("foo");

bcft.method1("bar"); // Works fine

bcft.method1(123); // "Argument of type 'number' is not assignable to parameter of type 'string'."

The above is reflected in changes done in DataStream and StringStream classes. And well, it works 🎉

This PR doesn't solve the issue with mutating stream in/out types (as described in #23 (comment)) but it is a necessary "intro" we need to have to solve it correctly. That's also the reason why map was left untouched for now.

Unit tests introduced in this PR checks both runtime types (instanceof and constructor.name) as well as compile time types (by trying to call .split() method on resulting stream which fails if it is not a StringStream decalred return type).

@f1ames f1ames marked this pull request as ready for review November 5, 2021 15:39
@scramjet-bot
Copy link

@f1ames f1ames changed the title Polymorphic/generic this as return type for subclasses (WIP) Polymorphic/generic this as return type for subclasses Nov 5, 2021
Copy link

@ErykSol ErykSol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nicely done!!!

Copy link
Contributor

@jan-warchol jan-warchol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good job!! 👍 🥇

This commit is trying to solve the issue of "polymorphic generic this
as a return type".

For more in-depth description see:
#28.

This issue is related to a fact that in `DataStream<T>` class we have
multiple methods (transfroms) which returns `DataStream<U>` (so the
instance of the same class, but with different type parameter). This
works fine for `DataStream` itself but causes problems for derived
classes as a return type is not dynamic and inhertied methods called
on an instance of derived class will always return the instance
of a parent class.

First change was to introduce `.create()` method which is used inside
tramsforms directly instead of calling `new DataStream...`. This allows
derived classes to override it and have control over how its instances
are created. With `.create()` in use, new instance based on call context
(current this) is created. This results with a correct return type
during runtime.

The second change was to override transforms signatures in the derived
class so correct return type could be declared. Those overriden
transforms call `super.transform(...)` internally so the logic stays
exactly the same. This solves the issue of TS not correctly recognizing
return type (as without it, method signatures declare base class
as return type).

Above works well for generic derived classes. However, for non-generic
derived classes (like `StringStream` in our case), overridden transform
signatures (and also `.create()` method) needs to be non-generic as well
to work with this approach. And so the third change was to overload
transforms in a base class to have both non-generic and generic versions.
@f1ames
Copy link
Contributor Author

f1ames commented Nov 10, 2021

Moved generic-this-1.ts to samples folder. Added changes description to fixing commit.

@f1ames
Copy link
Contributor Author

f1ames commented Nov 15, 2021

Rebased onto latest main.

@f1ames f1ames merged commit c00e628 into main Nov 15, 2021
@f1ames f1ames deleted the task/generic-this branch November 15, 2021 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants