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 type parameters in base class expressions #26542

Closed
4 tasks done
thejohnfreeman opened this issue Aug 19, 2018 · 8 comments
Closed
4 tasks done

Allow type parameters in base class expressions #26542

thejohnfreeman opened this issue Aug 19, 2018 · 8 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@thejohnfreeman
Copy link

thejohnfreeman commented Aug 19, 2018

Search Terms

typescript type parameters in base class expressions

Suggestion

Type parameters in base class expressions.

Use Cases and Examples

Let's start with a simple mixin [playground] (borrowed from proposal #13743):

type Constructor<T = {}> = new (...args: any[]) => T;

class Point {
  constructor(
    public readonly x: number,
    public readonly y: number,
  ) {}
}

// Tagged is a mixin with a type parameter.
const Tagged = <T, B extends Constructor = B>(Base: B) =>
  class extends Base {
    getTag(): T {
      // Just imagine I have a function that returns objects,
      // and I want to case them to T.
      return {} as T
    }
  }

class TaggedPoint extends Tagged(Point) {}

Now if I want to specify the type argument for Tagged, that's easy enough 1 [playground]:

class TaggedPoint extends Tagged<number>(Point) {}

But if I want to pass a derived class's type parameter as that argument, I cannot [playground]:

class UsesTheTag<T> extends Tagged<T>(Point) {
//                                 ^
// error: Base class expressions cannot reference
// class type parameters.
  useTag() {
    const tag: T = this.getTag()
  }
}

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. new expression-level syntax)

Footnotes

  1. Note, this requires the default type argument = B shown above, but I have never seen it used in documentation, Stack Overflow, or blogs on the issue of TypeScript mixins. Is that because TypeScript is not actually deducing the type parameter from the function argument?

@mattmccutchen
Copy link
Contributor

mattmccutchen commented Aug 20, 2018

This alternative is working for me (playground):

type Constructor<T = {}> = new (...args: any[]) => T;

class Point {
  constructor(
    public readonly x: number,
    public readonly y: number,
  ) {}
}

const Tagged = (Base: Constructor) =>
  class <T> extends Base {
    getTag(): T {
      // Just imagine I have a function that returns objects,
      // and I want to case them to T.
      return {} as T
    }
  };

class UsesTheTag<T> extends Tagged(Point)<T> {
  useTag(): T {
    const tag: T = this.getTag()
    return tag
  }
}

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Aug 20, 2018
@RyanCavanaugh
Copy link
Member

See #26154 - trying to use an instance type parameter in a base class expression is a conceptual error

@mattmccutchen
Copy link
Contributor

mattmccutchen commented Aug 20, 2018

Oops, my code above doesn't actually work because UsesTheTag<T> isn't seen as a subtype of Point. Never mind. This is what I'd like to write, but it gives an error:

type Constructor<T = {}> = new (...args: any[]) => T;

class Point {
  constructor(
    public readonly x: number,
    public readonly y: number,
  ) {}
}

const Tagged = <B extends Constructor>(Base: B) =>
  // Error: A mixin class must have a constructor
  // with a single rest parameter of type 'any[]'.
  class <T> extends Base {
    getTag(): T {
      // Just imagine I have a function that returns objects,
      // and I want to case them to T.
      return {} as T
    }
  };

class UsesTheTag<T> extends Tagged(Point) {
  useTag(): T {
    const tag: T = this.getTag()
    console.log(this.x);
    return tag
  }
}

Why aren't mixin classes allowed to have type parameters?

@thejohnfreeman
Copy link
Author

thejohnfreeman commented Aug 20, 2018

Something else that confuses me, why does this first declaration work but not the second? Why that error at that source location?

const Tagged = (Base: Constructor) =>
  class <T> extends Base {
    getTag(): T {
      // Just imagine I have a function that returns objects,
      // and I want to case them to T.
      return {} as T
    }
  }

const Tagged = <B extends Constructor> (Base: B) =>
  class <T> extends Base {
//~~~~~
// error: A mixin class must have a constructor with a single rest parameter of type 'any[]'.
    getTag(): T {
      // Just imagine I have a function that returns objects,
      // and I want to case them to T.
      return {} as T
    }
  }

@mattmccutchen
Copy link
Contributor

mattmccutchen commented Aug 20, 2018

I think I answered my own question: the original superclass could be generic, and any type arguments in the extends clause are passed to the original superclass, not to the mixin. The mixin is not allowed to have type parameters because there would be no way to pass type arguments. Clearly someone thought about this but didn't see fit to write any documentation. ☹️ Hopefully this thread will serve as documentation for the next person to search for the error message.

type Constructor<T = {}> = new (...args: any[]) => T;

class Point<U> {
  constructor(
    public readonly x: U,
    public readonly y: U,
  ) {}
}

const Tagged = <B extends Constructor>(Base: B) =>
  class extends Base {
    getTag(): string {
      return {} as string
    }
  };

class UsesTheTag<U> extends Tagged(Point)<U> {
  useTag(): string {
    const tag: string = this.getTag()
    console.log(this.x);
    return tag
  }
}

So if we want a derived class that passes a type argument to a mixin, we'll have to go with this approach.

@thejohnfreeman
Copy link
Author

thejohnfreeman commented Aug 20, 2018

I think we're getting off track. Here is the fixed version of what you gave earlier, in which I can use the facilities of both Tagged and Point on the final type. The only piece you forgot was to pass Point as the type argument to Constructor (playground).

type Constructor<T = {}> = new (...args: any[]) => T;

class Point {
  constructor(
    public readonly x: number,
    public readonly y: number,
  ) {}
}

const Tagged = (Base: Constructor<Point>) =>
  class <T> extends Base {
    getTag(): T {
      // I can use the facilities of the base class
      // (in this case, Point) because I declared that `Base`
      // is a `Constructor` of that class.
      const x: number = this.x
      // Constructions of my class assert the type that I return,
      // so that they get type safety when working with the
      // objects I return.
      return { tag: 'it' } as any as T
    }
  };

class UsesTheTag<T> extends Tagged(Point)<T> {
  useTag(): T {
    // I can use the facilities of the base class (Tagged)
    // and *its* base class (Point).
    const tag: T = this.getTag()
    const x: number = this.x
    return tag
  }
}

// When I construct an object, I declare the type that its
// `getTag` and `useTag` methods return.
const tp = new UsesTheTag<{ tag: string }>(1, 2)
// When I call `getTag` or `useTag`, TypeScript checks that
// I use the return value in accordance with the tag type
// I declared.
const tag = tp.getTag()
console.log(tag.tag)
const sameTag = tp.useTag()
console.log(tag.tag)
// `tp` should also be a Point. These should work, just as they
// did within the implementation of `getTag` and `useTag`.
const x: number = tp.x
const y: number = tp.y

I'm going to consider my original question answered. Thanks for the help! In this case, a type parameter in a base class expression is not what I wanted; I wanted a type parameter in the class expression returned by the mixin.

There is a follow-up question that I think brushes up against the higher-kinded types limitation that has been documented elsewhere. I haven't seen a well put-together example, though, so I'm going to take this opportunity to try to contribute my own (playground).

type Constructor<T = {}> = new (...args: any[]) => T;

// The base class.
class Point {
  constructor (
    public readonly x: number,
    public readonly y: number,
  ) {}
}

// An extended base class with additional facilities.
class WeightedPoint extends Point {
  public readonly weight: number = 1
}

// My mixin just needs the facilities of the base class.
const SummedSquared = (Base: Constructor<Point>) =>
  class extends Base {
    getSumOfSquares(): number {
      return this.x * this.x + this.y * this.x
    }
  };

// My final subtype expects the extended base class
// because it needs those additional facilities,
// but it also wants the mixin applied.
class SummedSquaredWeightedPoint extends SummedSquared(WeightedPoint) {
  getWeightedSumOfSquares(): number {
    // I want to use the facilities of the base class of
    // the base class (`WeightPointed`), but TypeScript only
    // knows that the base class is a `Point` because that is
    // what the mixin declared.
    return (
      this.x * this.x * this.weight +
      this.y * this.y * this.weight
      //                     ~~~~~~
      // error: Property 'weight' does not exist on type 'SummedSquaredWeightedPoint'.
    )
  }
}

@admosity
Copy link

@thejohnfreeman This playground solves your follow-up question.

Before:

const SummedSquared = (Base: Constructor<Point>) =>
  class extends Base {
    getSumOfSquares(): number {
      return this.x * this.x + this.y * this.x
    }
  };

After:

const SummedSquared = <T extends Constructor<Point>>(Base: T) =>
  class extends Base {
    getSumOfSquares(): number {
      return this.x * this.x + this.y * this.x
    }
  };

Pulls the types quite nicely:

image


image

@aoi-umi
Copy link

aoi-umi commented Dec 3, 2018

i have the same question

import * as mongoose from 'mongoose';
export declare type InstanceType<T> = T & mongoose.Document;
export declare type ModelType<T, typeofT> = mongoose.Model<InstanceType<T>> & typeofT;

declare var _Model1: <T>(t?: T) => mongoose.Model<InstanceType<T>>;
export declare class Model<T, DocOmit={}> extends _Model1() {
    createdAt?: Date;
    updatedAt?: Date;
}

type FooModelType = ModelType<Foo, typeof Foo>;
class Foo extends Model<Foo>{
    static async method() {
        //findOne is defined in mongoose Model        
        // interface Model<T extends Document> extends NodeJS.EventEmitter, ModelProperties {    
        //     findOne(conditions?: any,
        //   callback?: (err: any, res: T | null) => void): DocumentQuery<T | null, T>;
        // }
        let rs = await this.findOne();
        //and now the result is mongoose.Document
        
        //but i want to get InstanceType<Foo> 
        let self = this as FooModelType;
        let rs2 = await self.findOne();
        //what i need is 
        //class Model<T, DocOmit={}> extends _Model1<T>() {}
    }
}

default
default

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

5 participants