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

TS1092: Type parameters cannot appear on a constructor declaration #10860

Closed
yortus opened this issue Sep 12, 2016 · 14 comments
Closed

TS1092: Type parameters cannot appear on a constructor declaration #10860

yortus opened this issue Sep 12, 2016 · 14 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@yortus
Copy link
Contributor

yortus commented Sep 12, 2016

TypeScript Version: nightly (2.1.0-dev.20160906)

Code

// (A) declare Wrapper class directly - THIS DOESN'T WORK
// The errors below are all 'TS1092: Type parameters cannot appear on a constructor declaration'
declare class Wrapper {
    constructor<TR>(wrapped: () => TR): NullaryWrapper<TR>; // ERROR
    constructor<TR, T0>(wrapped: ($0: T0) => TR): UnaryWrapper<TR, T0>; // ERROR
    constructor<TR, T0, T1>(wrapped: ($0: T0, $1: T1) => TR): BinaryWrapper<TR, T0, T1>; // ERROR
    invoke(...args: any[]): any;
}


// (B) declare Wrapper as static side + instance side interfaces - THIS WORKS FINE
// This is equivalent to the class declaration in (A), but in this case it works without errors.
declare let WrapperConstructor: {
    new<TR>(wrapped: () => TR): NullaryWrapper<TR>;
    new<TR, T0>(wrapped: ($0: T0) => TR): UnaryWrapper<TR, T0>;
    new<TR, T0, T1>(wrapped: ($0: T0, $1: T1) => TR): BinaryWrapper<TR, T0, T1>;
}

// Some strongly-typed versions of the Wrapper shape for specific arities
interface NullaryWrapper<TR> extends Wrapper        {   invoke(): TR;               }
interface UnaryWrapper<TR, T0> extends Wrapper      {   invoke($0: T0): TR;         }
interface BinaryWrapper<TR, T0, T1> extends Wrapper {   invoke($0: T0, $1: T1): TR; }


// Some functions to be wrapped
const f0 = () => 42;
const f1 = (s: string) => 42;
const f2 = (s: string, n: number) => 42;


// Create some wrappers using the Wrapper class - DOESN'T WORK
new Wrapper(f0).invoke;  // ERROR
new Wrapper(f1).invoke;  // ERROR
new Wrapper(f2).invoke;  // ERROR


// Create some wrappers using the WrapperConstructor var/interface - WORKS NICELY
new WrapperConstructor(f0).invoke;  // () => number
new WrapperConstructor(f1).invoke;  // ($0: string) => number
new WrapperConstructor(f2).invoke;  // ($0: string, $1: number) => number

I'm trying to infer a strongly-typed shape for the class instance from the arguments passed to the constructor. There is no runtime shenanigans, this is purely for improved type-checking.

Are the TS1092 errors in the code at (A) above really necessary, given that the equivalent code at (B) works fine? I'd rather be able to write the Wrapper class directly than use the WrapperConstructor workaround of a var/interface combination.

They do appear to be equivalent typewise, but the (B) version seems a bit hacky. And if the compiler can accept (B), couldn't it also allow (A)?

Or is there a better way to do what I'm trying to do here?

@Arnavion
Copy link
Contributor

(A) makes no sense even without the type parameters. Constructors can't have a return type annotation because constructors don't return anything, and even if they did it would be the same class as what they're defined in.

The error about type parameters is just hiding the error about return annotations: error TS1093: Type annotation cannot appear on a constructor declaration.

For the same reason it doesn't make sense for constructors to have type parameters. The type parameters would go on the class they're a part of.

@yortus
Copy link
Contributor Author

yortus commented Sep 12, 2016

@Arnavion (A) does make sense - it's the same thing as (B). If (A) made no sense, then neither should (B), but that compiles fine.

(A) is returning an instance of the same class, just with more specific compile-time typing. It works at runtime, and it also works using the interface approach in (B) which amounts to the same thing.

BTW constructors do return something, even in ES6. TypeScript even provides intellisense for their return type. It's just that the compiler applies different rules to class { constructor(...)} than it does to the equivalent interface { new(...) }.

@Arnavion
Copy link
Contributor

I was going to edit this in right before you answered:

Also, while TS does allow you to write (B), you can't implement it in the way your type signatures intend at runtime either.

Of course both of these may change depending on how #7574 is fixed, i.e., if TS did allow you to return arbitrary things from constructors.

@yortus
Copy link
Contributor Author

yortus commented Sep 12, 2016

To be clear, the code does not return arbitrary things from the constructor, it returns valid instances of the Wrapper class. The only difference is the compile-time type-checking of the instances, which are given a more specific shape, but one that is still compatible with the class. The compiler can deal with this just fine, as (B) shows. It just won't allow the equivalent formulation (A).

Also, while TS does allow you to write (B), you can't implement it in the way your type signatures intend at runtime either.

Can you elaborate on this? The implementation I'm using with (B) works fine.

@Arnavion
Copy link
Contributor

To be clear, the code does not return arbitrary things from the constructor, it returns valid instances of the Wrapper class.

Yes, I understand that. I assume your invoke is something like function (...args) { return wrapped(...args); }, and you're trying to get additional typing by introducing interfaces corresponding to the wrapped function.

The implementation I'm using with (B) works fine.

I'm saying you cannot implement (B), i.e. the actual implementation of AltWrapper, in TS. I assume you have a JS implementation and you're just trying to model it in TS.

Now, if you're able to change the implementation, you could switch from class Wrapper { constructor(func); invoke(); } to function Wrapper(func): NullableWrapper<> and other overloads, where NullableWrapper , etc are functions themselves with the same type parameters. That can be modeled in existing TS just fine.

@yortus
Copy link
Contributor Author

yortus commented Sep 12, 2016

@Arnavion here's a working implementation of (B). It's 100% TypeScript, works at runtime, and achieves the desired goal of getting compile-time type-inference from the arg passed to the constructor:

// 'Workaround' for achieving compile-time type inference with a constructor
interface WrapperConstructor {
    new<TR>(wrapped: () => TR): NullaryWrapper<TR>;
    new<TR, T0>(wrapped: ($0: T0) => TR): UnaryWrapper<TR, T0>;
    new<TR, T0, T1>(wrapped: ($0: T0, $1: T1) => TR): BinaryWrapper<TR, T0, T1>;
}
interface Wrapper                                   {   invoke(...args: any[]): any;}
interface NullaryWrapper<TR> extends Wrapper        {   invoke(): TR;               }
interface UnaryWrapper<TR, T0> extends Wrapper      {   invoke($0: T0): TR;         }
interface BinaryWrapper<TR, T0, T1> extends Wrapper {   invoke($0: T0, $1: T1): TR; }

let Wrapper: WrapperConstructor = class {
    constructor(private wrapped: Function) { }
    invoke(...args) {
        let result = this.wrapped(...args);
        console.log(`WRAPPED RESULT: ${result}`);
        return result;
    }
};

// Above code is runtime-valid...
new Wrapper(() => 42).invoke();                 // prints 'WRAPPED RESULT: 42'
new Wrapper((n: number) => n * 2).invoke(10);   // prints 'WRAPPED RESULT: 20'

// ...and the invoke method has its type inferred accurately from the ctor argument
new Wrapper((n: number) => n * 2).invoke('foo');// ERROR: 'string' not assignable to 'number'
new Wrapper((a,b) => a+b).invoke();// ERROR: supplied parameters do not match signature

The WrapperConstructor + Wrapper interfaces are just the decomposed static-side and instance-side of a class, no more and no less. I've provided the runtime implementation as a class expression.


I'd prefer to just be able to write the equivalent class directly without having to split up into static/instance-side interfaces just to get it to compile. Note the implementation of the class is
identical to the class expression used above, except the constructor has some compile-time-only overloads with the appropriate annotations.

// NB: This doesn't compile
class Wrapper {
    constructor<TR>(wrapped: () => TR): NullaryWrapper<TR>;
    constructor<TR, T0>(wrapped: ($0: T0) => TR): UnaryWrapper<TR, T0>;
    constructor<TR, T0, T1>(wrapped: ($0: T0, $1: T1) => TR): BinaryWrapper<TR, T0, T1>;
    constructor(private wrapped: Function) { }
    invoke(...args) {
        let result = this.wrapped(...args);
        console.log(`WRAPPED RESULT: ${result}`);
        return result;
    }
}
interface NullaryWrapper<TR> extends Wrapper        {   invoke(): TR;               }
interface UnaryWrapper<TR, T0> extends Wrapper      {   invoke($0: T0): TR;         }
interface BinaryWrapper<TR, T0, T1> extends Wrapper {   invoke($0: T0, $1: T1): TR; }

@yortus
Copy link
Contributor Author

yortus commented Sep 12, 2016

I've been trying to find a page that I think was in the handbook which showed that you could always rewrite a class declaration as a pair of interface declarations, one for the static side and one for the instance side. I've only found this, with an example ClockConstructor + ClockInterface.

There's a purely mechanical transform between these two formulations of a class type, and I thought they were supposed to be more-or-less identical to the type system. That's why I'm suggesting that the two formulations should work the same in the OP here. Either they should both work (I'd prefer this), or they should both give the same error.

@Arnavion
Copy link
Contributor

here's a working implementation of (B). It's 100% TypeScript

Right, you're accepting a Function parameter and asserting that you're using it correctly. I meant you couldn't write a type-safe implementation.

I've been trying to find a page that I think was in the handbook which showed that you could always rewrite a class declaration as a pair of interface declarations, one for the static side and one for the instance side.

Yes, I already know you can do this. But usually when one does this the static side only has one construct signature (that returns the same type as the instance side), or multiple construct signatures that return the same type as the instance side.

Personally I think TS is being lenient by allowing you to have construct signatures targeting arbitrary types in the first place, atleast until #7574 is implemented. It makes no sense in general.

But anyway, I get your point. No more argument from me.

@RyanCavanaugh
Copy link
Member

The reason you can't write type parameters on constructors is that there's no place to specify them in a new invocation of a generic class:

class Foo<T> {
  constructor<U>(x: T, y: U) { }
}
let g = new Foo<number>(..., ...); // T: ?, U: ?

The main difference between a class and its static decomposition is that we believe the former has a unified return type of new, but not the latter. For an actual ES6 class this is generally a safe assumption, but older JS code can behave arbitrarily weirdly when invoked with new so we allow mixed return types from new for the sake of representing existing libraries.

@mhegazy mhegazy added the Working as Intended The behavior described is the intended behavior; this is not a bug label Sep 12, 2016
@yortus
Copy link
Contributor Author

yortus commented Sep 13, 2016

Thanks for explaining @RyanCavanaugh. I don't mind sticking to the decomposed approach.

Interestingly the point about having no place to specify the type parameters on a new invocation equally applies to the decomposed approach, but the compiler works with it just fine and it's actually very useful. The type parameters must be inferred from the arguments, since you can't write them explicitly (but inference is the whole point of doing this anyway).

If the OP problem could be solved with a type parameter on the class I'd do that, but I don't think it's possible because of the variadic number of type parameters needed to capture the various function signatures.

So is there any guidance here? Would #5453 provide a way to do this? Something like:

// Using variadic kinds from #5453
declare class Wrapper<...TParams, TReturn> {
    constructor(wrapped: (...args: ...TParams) => TReturn);
    invoke(...args: ...TParams): TReturn;
}

@yortus
Copy link
Contributor Author

yortus commented Sep 13, 2016

Slightly offtopic since the technique is not used here, but why does TypeScript assume ES6 classes have a unified return type from new, differently from ES5 constructor functions? They both support returning something other than this, even a completely different (object) type. The semantics didn't change much w.r.t. constructor return values from ES5 --> ES6.

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Sep 13, 2016

why does TypeScript assume ES6 classes have a unified return type from new, differently from ES5 constructor functions? They both support returning something other than this

This is definitely technically true, but it's really hard to imagine why you'd bother writing an ES6 class if you were going to return something else out of the constructor. Really something that returns a different object shape from its constructor isn't a "class" as most people classically imagine it. ES5 was a lot more of a wild-west where library authors would often[ish] pretend the new operator wasn't there, or vice versa. TL;DR pragmatism.

@yortus
Copy link
Contributor Author

yortus commented Sep 13, 2016

it's really hard to imagine why you'd bother writing an ES6 class if you were going to return something else out of the constructor.

@RyanCavanaugh here is a real example of returning something else from the constructor in an ES6 class. The example there creates a callable object using a constructor, preserving instanceof behaviour as well. It's effectively a way of subclassing Function, and AFAIK there's no other practical way to do this (well you can hack the prototype chain but I'm skipping that).

Of course you can create an ES6 class that subclasses Function. But that's mostly useless in practice, because the subclass' callable body cannot be a closure and therefore cannot close over any of properties of the class instance or objects passed to the constructor. That's because of the requirement to delegate to super(...) which is the Function constructor, which always evaluates its source code in global scope.

@billba
Copy link
Member

billba commented May 11, 2017

Now that we have default generics, I'd like to open this back up, because now the type parameters don't need to be specified in a new invocation of a generic class.

My situation is that I'm doing some functional programming fu where I'd like to pass a chain of functions which represent transforms.

class Foo<A, Z> {
    constructor(f1: (a: A) => Z)
    constructor<B = any>(f1: (a: A) => B, f2: (b: B) => Z)
    constructor<B = any, C = any>(f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => Z)
    constructor<B = any, C = any, D = any>(f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => Z)
    constructor(... functions: ((a: any) => any)[]) {
        ...
    }
}

I don't care what types B-D are, so long as they form a consistent chain. When the compiler can't infer them, it can use the defaults. In my actual code I'd probably use a more constrained default than "any" but same idea applies.

I propose that TypeScript allow generic type parameters on constructors when they have defaults. I'm afraid I'm not enough of a compiler geek to provide a more detailed proposal, but maybe someone else can help out?

@microsoft microsoft locked and limited conversation to collaborators Jun 19, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

5 participants