Skip to content

Method signatures containing references to properties of constrained generic types are resolving through the constraint instead of the supplied type argument #12651

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

Closed
rotemdan opened this issue Dec 4, 2016 · 6 comments · Fixed by #12770
Labels
Fixed A PR has been merged for this issue

Comments

@rotemdan
Copy link

rotemdan commented Dec 4, 2016

TypeScript Version: nightly (2.2.0-dev.20161203)

I currently find it somewhat hard to precisely describe the issue/suggestion, but I believe this use case should demonstrate it clearly:

type MethodDescriptor = {
	name: string;
	args: any[];
	returnValue: any;
}

function dispatchMethod<M extends MethodDescriptor>(name: M['name'], args: M['args']): M['returnValue'];

type SomeMethodDescriptor = {
	name: "someMethod";
	args: [number, string];
	returnValue: string[];
}

// Currently, there's no compilation error on the the invalid second argument type and the 
// return type is inferred as "any" instead of "string[]":
let result = dispatchMethod<SomeMethodDescriptor>("someMethod", ["hello", 35]);

The general idea here is that the types are used unconventionally: not to describe interfaces to run-time objects but as a way to "pack" a set of type arguments into an "object-like" compile-time entity. Perhaps this could be called something like "packed type arguments" or "type argument classes" or maybe "type argument schema containers"? I'm not sure.

Anyway, I was trying to write a general purpose dispatcher that can invoke a set of actions, and that seemed like a more elegant and type safe alternative to the more conventional:

function dispatchMethod<A extends any[], R>(name: string, args: A): R;

dispatchMethod<[number, string], string[]>("someMethod", ["hello", 35]); // <- errors as expected
@rotemdan rotemdan changed the title References to properties of constrained generic types are resolving through the constraint instead of the supplied type argument Method signatures containing references to properties of constrained generic types are resolving through the constraint instead of the supplied type argument Dec 4, 2016
@rotemdan
Copy link
Author

rotemdan commented Dec 4, 2016

A convenient way to illustrate this is that:

type MethodDescriptor = {
	name: string;
	args: any[];
	returnValue: any;
}

Represents a set of base constraints for a compile-time "object-like" entity containing named type arguments, e.g.

  1. The type name must be assignable to string.
  2. The type args must be assignable to any[].
  3. The type returnValue must be assignable to any.

And:

type SomeMethodDescriptor = {
	name: "someMethod";
	args: [number, string];
	returnValue: string[];
}

Represents an "instance" or "implementation" of the MethodDescriptor "interface", in the sense that it associates more specific types to its properties.

Now the function signature:

function dispatchMethod<M extends MethodDescriptor>(name: M['name'], args: M['args']): M['returnValue'];

References the types of properties on the type argument M.

And the function call:

let result = dispatchMethod<SomeMethodDescriptor>("someMethod", ["hello", 35]);

Passes SomeMethodDescriptor as a purely compile-time "schema container" for the types that are expected as arguments to dispatchMethod and of its return value.

Note that in this example both the types MethodDescriptor and SomeMethodDescriptor are never actually applied to any concrete run-time object.

I know this may be seen as somewhat abstract so I hope this helps..

@rotemdan
Copy link
Author

rotemdan commented Dec 4, 2016

In combination with discriminated unions, it might be possible to encapsulate the dispatcher in a class such that only one "type schema" is actually needed to be supplied once during construction to describe all supported actions:

type MethodDescriptor = {
	name: string;
	args: any[];
	returnValue: any;
}

class Dispatcher<M extends MethodDescriptor> {
	dispatch(name: M['name'], args: M['args']): M['returnValue'] {
		// ...
	}
}

type StoreMethodDescriptor = {
	name: "store";
	args: [string, number[], boolean];
	returnValue: void;
}

type OpenMethodDescriptor = {
	name: "open";
	args: [string];
	returnValue: number;
}

const dispatcher = new Dispatcher<StoreMethodDescriptor | OpenMethodDescriptor>();

// The correct argument types and return type are inferred and validated automatically 
// using the string literal as discriminant:
dispatcher.dispatch("store", ["mydata.txt", [1, 2, 3, 4], true]);

@rotemdan
Copy link
Author

rotemdan commented Dec 7, 2016

I realized there's a subtle assumption I introduced here in my discriminated union example.

Consider this case:

type MyType = { a: string; b: number } | { a: boolean; b: string[] };

function test(x: MyType['a'], y: MyType['b']) {}
test("hi", ["a", "b", "c"]); // <- currently doesn't error

Here, MyType['a'] is currently inferred as string | boolean and MyType['b'] is currently inferred as number | string[], so test("hi", ["a", "b", "c"]) currently doesn't error.

Is this the right way to go? maybe? though the case I described was slightly different:

class Test<T extends { a: any; b: any }> {
	test(x: T['a'], y: T['b']) {
		// ...
	}
}

const instance = new Test<{ a: string; b: number } | { a: boolean; b: string[] }>();

instance.test("hi", ["a", "b", "c"]); // <- Should this error?

Should both T['a'] and T['b'] be required to be consistent with a unique member of the union? I'm not completely sure. This could be seen as a slight ambiguity in the syntax, though. Perhaps this might benefit from a helper syntax to constrain a type parameter not to be a union, something like uniquely extends :

class Test<T extends { a: any; b: any }> {
	test<M uniquely extends T>(x: M['a'], y: M['b']) {
		// ...
	}
}

const instance = new Test<{ a: string; b: number } | { a: boolean; b: string[] }>();

instance.test("hi", ["a", "b", "c"]); // <- Errors as M is constrained not to be a union

The suggested keyword might not prove to be that great though, I'm not sure.. perhaps there's a better one, or a different approach altogether..

@rotemdan
Copy link
Author

rotemdan commented Dec 8, 2016

A simpler alternative to uniquely extends is to use something like specifies instead of extends for the main type parameter:

class Test<T specifies { a: any; b: any }> {
	test(x: T['a'], y: T['b']) {
		// ...
	}
}

const instance = new Test<{ a: string; b: number } | { a: boolean; b: string[] }>();

instance.test("hi", ["a", "b", "c"]); // <-- Errors due to the semantics of "specifies".

Although specifies can be a bit ambiguous as it might suggest the type argument can't be a union (in the sense it must be "specific"), I think the potential confusion is only minor, and the reduced complexity and increased user friendliness may be worth it.

Another advantage of specifies is that it may support future syntax that wouldn't be appropriate for extends. Anyway, the best name I found so far for this concept is type schemas, so specifies seems to go well with that.

@ahejlsberg
Copy link
Member

The issue described in the original post is now fixed by #12770.

@rotemdan
Copy link
Author

rotemdan commented Dec 8, 2016

@ahejlsberg

Thanks! Maybe I'll try to somehow apply the patch tomorrow (or wait until it's in the nightly build) and see if I can start making use of this new inference capability in my code.

If you find any of these additional ideas I mentioned here interesting feel free to make use of them in the future. They were originally meant as motivating examples. I'm not sure, though, whether I'm in a good position to figure out a whole proposal from them at this point. It would be interesting, perhaps, to first see what other people would do / want to do with these inference patterns and then try to come to some form of understanding what's the 'best' way to approach this.

@rotemdan rotemdan closed this as completed Dec 8, 2016
@mhegazy mhegazy added the Fixed A PR has been merged for this issue label Jan 3, 2017
@mhegazy mhegazy added this to the TypeScript 2.1.5 milestone Jan 3, 2017
@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
Fixed A PR has been merged for this issue
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants