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

Feature Request: Declared constructor return types #27465

Closed
4 tasks done
conartist6 opened this issue Sep 30, 2018 · 9 comments
Closed
4 tasks done

Feature Request: Declared constructor return types #27465

conartist6 opened this issue Sep 30, 2018 · 9 comments
Labels
Duplicate An existing issue was already created

Comments

@conartist6
Copy link

conartist6 commented Sep 30, 2018

Search Terms

Constructor type return, generic class constructor, newable

Suggestion

Make it possible for class constructors to be functions with generic return types.

Here is a very simple example of what I mean:

declare class Foo<T> {
    constructor(): Foo<string>;
    constructor(foo: T): Foo<T extends string ? string : T>;
    value: T;
}

Use Cases

The major use case here is self documenting code. In addition to being a programming language, Typescript should (and does) serve as a formal way to describe types in well-typed programs. If Typescript is one of the first things that a new javascript developer learns, it should pay dividends in being able to read published APIs as described by their types.

The existing syntax requires that constructor functions are not generic and do not have return types. This is restrictive in what it allows. The ternary example above is not possible, nor is any other usage of "imperative" type transformations. It also forbids code which has different type parameter defaults per overload.

In all examples like the one above where existing syntax is not sufficiently powerful, Typescript currently falls back to another syntax, interfaces. The opening example can be rewritten using existing functionality:

interface Foo<T> {
    value: T;
}
interface FooConstructor {
    new(): Foo<string>;
    new <T>(foo: T): Foo<T extends string ? string : T>; 
}
declare var Foo: FooConstructor;

This version however, while satisfying to the type checker, is no longer very effective for communicating to a person, especially one who may be viewing this code as public documentation, what behavior to expect.

Examples

Another example is Typescript's definitions for es6 data structures:
https://github.com/Microsoft/TypeScript/blob/837df49a668384aaee034a34df62f16783798a1b/src/lib/es2015.collection.d.ts

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)

This is not a breaking change, because today a return type specified for a constructor function is an explicit error. The parser already understands a type appearing in that position, it has just been told that it's illegal.

This particular syntax is actually the one used already by flow, making it even more valuable as a way to write easy-to-read documentation of class types that will be readable to people coming from the background of either type system.

@conartist6
Copy link
Author

conartist6 commented Sep 30, 2018

Some careful thought would be necessary as to what return types would be considered valid. My use case would be served by the most conservative decision there, which would be to validate that the type appearing in the constructor return position must be an instance of the class.

Of course since es6 allows arbitrary class constructor returns (as does the interface syntax) there's also an argument to be made for being fully permissive with the constructor return type (any non-null object or function) even while, say, adding a lint rule which might discourage constructor returns which are not instances of the class.

@conartist6
Copy link
Author

I'm looking further into the formal elements of a proposal for this. Will track progress here. First interesting tidbit comes from: the language specification says the following:

The following example introduces both a named type called 'Point' (the class type) and a named value > called 'Point' (the constructor function) in the containing declaration space.

class Point {  
    constructor(public x: number, public y: number) { }  
    public length() { return Math.sqrt(this.x * this.x + this.y * this.y); }  
    static origin = new Point(0, 0);  
}

The named type 'Point' is exactly equivalent to

interface Point {  
    x: number;  
    y: number;  
    length(): number;  
}

The named value 'Point' is a constructor function whose type corresponds to the declaration

var Point: {  
    new(x: number, y: number): Point;  
    origin: Point;  
};

If a class declaration is essentially just a sugar for creating an underlying set of interface types and the interface type supports constructor return functionality, all that would need to be done is to figure out the new semantics involved in transforming one form to the other.

@yortus
Copy link
Contributor

yortus commented Oct 1, 2018

Related/duplicate: #10860

@conartist6
Copy link
Author

conartist6 commented Oct 1, 2018

Ah, dunno how I didn't find that. So my proposal is a little different I think because I'm not proposing that the constructor functions themselves be permitted to declare type parameters. This alleviates the concern expressed here regarding where it would be possible to specify those type parameters explicitly. Also I'm open to forcing the returns to actually be the instance types.

Additionally, my real use case: I'm building a data structures library wraps es6 Map and Set with the Immutable.js API. Immutable.js has great Typescript types, and its documentation is built from them. Immutable exports factory functions not classes, in particular because it reserves the right not to construct a new instance. I plan to expose actual classes, and to present my documentation as Typescript types built from a d.ts file, however this is at present not possible because there's no class constructor analog for the type signature those factory functions have.

@conartist6
Copy link
Author

conartist6 commented Oct 1, 2018

Actually after looking more carefully I see that Flow does allow constructor functions to declare their own type parameters, however it is not required for them to have return types which use class generics.

@RyanCavanaugh RyanCavanaugh added the Duplicate An existing issue was already created label Oct 1, 2018
@typescript-bot
Copy link
Collaborator

This issue has been marked as a duplicate and has seen no activity in the last day. It has been closed for automatic house-keeping purposes.

@conartist6
Copy link
Author

@yortus @RyanCavanaugh

I fully intend to reopen this issue and to gather support for my position. I have used custom tooling to document my project which allows me to generate this syntax even though it doesn't technically exist. I intend to alert readers of my documentation that the syntax they are reading, while it makes sense, is not valid Typescript, and to show them how complicated the actual syntax is. I will then request that they come to yet another copy of this issue that I will create (using simpler more direct terms) and add thumbs ups to it until somebody says that if I write the PR, it will not be rejected out of hand.

If anyone wants to take the more direct route to answering my question of whether such a PR would be acceptable right now, I'm all ears. It's too much work for me to update the language spec and write tests, etc just to find out I was wasting my time.

Here is the documentation site I have created using the as-yet-invalid syntax which, as you can see, reads very cleanly. It is concise, unambiguous, readable to the uninitiated, has parity with flow, and expresses exactly what is actually going on. Most importantly, though, there is no way to express it using the existing class syntax because while I would be content to infer string from the type of keys of Object, string|number is not an acceptable type in this circumstance since map keys differentiate between string and number.

@conartist6
Copy link
Author

Also may I suggest that if the bot is going to have a 24 hour period in which no activity will result in closure, it should announce that period on the thread to give the issue author a chance to do/say something.

@zareismail
Copy link

Another example is here. Builder design pattern implemented by Proxy

import camelcase from 'camelcase';

abstract class ProxyToGate {
  constructor() {
    return new Proxy(this, {
      get: function (parent, property, receiver): any {
        // handle exists method
        if (property in parent) {
          return parent[property as keyof typeof parent];
        }
        // handle unhandled promises
        if (property === 'then') {
          return new Promise((resolve) => {
            resolve(parent);
          });
        }
        // handle unhandled methods
        if (typeof parent.__call === 'function') {
          return parent.__call(property);
        }

        throw new Error('The "__call" method not implemented.');
      },
    });
  }

  /**
   * Proxy unhandled method and properties.
   *
   * @param {string | symbol} property
   * @returns {Function: any}
   */
  protected abstract __call(property: string | symbol): () => any;
}

interface Cache {
  set: () => void;
}

class Redis implements Cache {
  set() {
    console.log(this, '--------------', arguments);
  }
}

export default abstract class Manager<T, D extends T = T> extends ProxyToGate {
  protected drivers: { [key: string]: T } = {};

  /**
   * Get the driver instance.
   */
  driver<D extends T = T>(driver?: string): D {
    return this.resolveDriver<D>(driver ?? this.getDefaultDriver());
  }

  /**
   * Get name of the default driver.
   */
  abstract getDefaultDriver(): string;

  /**
   * Resolve the driver instance.
   */
  protected resolveDriver<D extends T = T>(driver: string): D {
    // cache driver if not initiated yet
    if (this.drivers[driver] === undefined) {
      this.drivers[driver] = this.createDriver(driver);
    }
    return this.drivers[driver] as unknown as D;
  }

  /**
   * Create new driver instance
   */
  protected createDriver(driver: string): T {
    const callback = this.driverCallback(driver) as keyof this;

    if (typeof this[callback] !== 'function') {
      throw new Error(`Creator method not found for driver '${driver}'.`);
    }
    return (this[callback] as Function)();
  }

  /**
   * Guess driver callback.
   */
  protected driverCallback(driver: string): string {
    return `create${camelcase(driver, { pascalCase: true })}Driver`;
  }

  /**
   * Proxy unhandled method and properties.
   */
  protected __call(property: string | symbol): () => any {
    return (...args) => {
      const driver: D = this.driver();
      const unhandled = driver[property as keyof D];

      return typeof unhandled === 'function'
        ? unhandled.apply(driver, args)
        : unhandled;
    };
  }
}

class CacheManager extends Manager<Cache, Redis> {
  /**
   * Get name of the default driver.
   */
  getDefaultDriver(): string {
    return 'redis';
  }

  createRedisDriver() {
    return new Redis();
  }
}

here typescript doesn't detect callable method since proxified

new CacheManager().set('a', 'b', 'c', 'd');

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Duplicate An existing issue was already created
Projects
None yet
Development

No branches or pull requests

5 participants