-
Notifications
You must be signed in to change notification settings - Fork 12.6k
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
Eliminate non-generic functions #17428
Comments
That is not correct. For example, in a function taking two parameters of type |
@ahejlsberg Given that TypeScript doesn't have ref returns or out variables, I don't really see where it would be useful to specifically assign one variable to another. Within the function body, both |
Is this an ask for non-local type inference in disguise? I don't see why already annotated parameters should be synthetically made polymorphic. |
@gcnew It is mainly a syntactic request rather than a semantic one. I want to hijack straightforward function definition syntax for declaring polymorphic functions, because in 99% of cases the resulting polymorphic function types are just as good (if not better) for people intending to write monomorphic functions. The current syntax for writing polymorphic functions like I'm not specifically asking for some new HM-esque inference strategy, although that would be easier to set up if you're already abstracting over and generating constraints for each parameter type. In this issue I'm just seeking a reduction in ceremony for declaring generic functions. |
Unfortunately, I don't see how polymorphic types could be assigned to parameters without doing the actual inference. Or do you mean only in the cases when the type parameters need no constraints? Also, I don't completely agree with your views on |
The type arguments are simply inferred from the types of the value arguments passed at the callsite, just as they are in today's generic functions. Nothing about the semantics of generic functions changes, we just start inferring generic function types for function expressions that were previously ascribed parametrically monomorphic function types. E.g. the expression
Perhaps I'm missing something, but the reasoning above seems to work even for cases where we have (subtype) constraints. After all, having no constraints is just the special case of being constrained to the top type (
The explicit I'm working with React higher-order components right now, and beyond a couple levels of nesting the type definitions basically flow off the screen. And this is with total separation of the type and implementation a la |
You might find #15114 interesting. TypeScript's unconstrained unions (i.e. unions between arbitrary types, not a predefined set of Explicit I've never done anything remotely serious with React, but I'd expect HKTs to be a bigger pain point than long parametric definitions. My guess is that parameter names are also big part of the problem (#13152). For the latter, I've been recently thinking about "shorthand" type annotations (inspired by Haskell): const id :: forall a. a -> a = x => x; // `::` denotes a shorthand type annotation
// note: forall quantifier is required as TS has no restrictions on type names
type Id = :: forall a. a -> a Not quite sure the new syntax would be worth the complexity and segregation it would add. |
Is the I'll look at #15114, although I don't see how that is relevant to this issue. Could you make this a bit more concrete? I understand that TypeScript generating unions out of thin air makes things difficult with respect to normalizing types, but that doesn't totally erase the utility of parametrically polymorphic functions; there's still many cases where doing |
Yes, that was my intention.
Unfortunately TypeScript has some difficulties with parametric polymorphism, but on the upside things have improved significantly.
To be honest, I think this suggestion is a dupe of #15114. However, even if it weren't, the discussion there provides insight what makes deducing polymorphic types hard, e.g. #15114 (comment). |
@gcnew It isn't a dupe of #15114, because that is asking for changes to type inference, whereas I'm asking for changes to syntax. To put this another way, if you were to implement what is being asked for in this issue, you'd only touch Whether or not deducing polymorphic types is hard is irrelevant; we have a simple mechanical way of transforming non-generic function declarations into generic function declarations at the AST level. How well or poorly type inference works with generic functions once they've been declared is a discussion for a different issue. |
I've understood your suggestion wrong. You've used the word inferred several times which had brought me a wrong impression. Now I see that your actual suggestion is to assign fresh type parameters to every function parameter that doesn't have an explicit type provided, instead of the current implicit function f(a, b, c: number) {
return a + c;
} Would be treated as if it were function f<A, B, C extends number>(a: A, b: B, c: C) {
return a + c; // an error here according to the existing rules
} I have mixed feelings on usability. Without inferring constraints (actual inference based on usage) on the introduced type parameters, I'm doubtful it would be very useful. And it doesn't seem very backward compatible either. On the other hand, it's a step in a more strict and sound direction. |
@gcnew Yes, that's exactly it; sorry about the confusion. I realized halfway through the conversation that "inferred" was the wrong word, perhaps "ascribe" is better. The intention is to make this as backward compatible as possible. As @ahejlsberg has pointed out, it isn't quite backward compatible if you intend to assign to parameters within the body of the receiving function. However if you treat parameters strictly as input positions (i.e. you treat a function as a contravariant generic type), this should be mostly backwards compatible. Wherever a parameter of monomorphic type Additionally, this would "standardize" parameter typing in a way that would set you up for improved inference, as proposed in #15114. E.g. when type checking a function body, for every parameter with no explicit bounds, you could simply emit additional bounds on the generic type where you would previously have emitted an error. It is true that within a function expression of the form If this modest break with backwards compatibility is deemed unacceptable, we have two alternatives that maintain total backwards compatibility:
|
Actually I would argue that |
@kitsonk The purpose is to make declaring polymorphic functions less verbose. E.g. this:
Would now be expressed as:
I don't see how |
Perhaps a compromise?
|
As noted by @ahejlsberg, the proposal does not take into consideration the difference in meaning between generic types parameters and other non-generic types. under this proposal, two strings are not comparable any more, since: function NonGeneric(a: string, b: string) {
a == b; // OK
}
function Generic<T extends string, U extends string>(a: T, b: U) {
a == b; // Not OK, since T and U are not comparable
} And that seems to be pretty fundamental. Moreover, as noted by @gcnew languages that use Hindly-milner type systems get a lot of mileage of this idea because the combine it with inference from call sites, and unifying constraints. just getting the everything-is-generic part puts a lot of constraints on function implementors, and makes using these types fairly onerous. Having a new syntax to define generic type parameters does not seem to be the right solution either. adding new constructs/syntax to the language increases learning and maintenance costs for both users and compiler maintainers. |
@mhegazy It seems like a bug for |
@mhegazy That's well and good, but they are both still instances of at least |
Putting it another way, if types are sets, |
@masaeedu: yeah, it does feel like the It does feel hard to get right though -- I get that they wanted to error on I do wonder where else this might matter. I think I can help with that question, as I've tried something similar in my PR for unwidened I think an opt-in proposal can be an improvement even with cons: if this proposal could improve inference for many scenarios, I believe having to annotate a few other cases would be fair, given a new compiler flag so users could opt in at their own leisure. |
@tycho01 I don't know if operators are given special treatment in TypeScript, but I'd expect it to work exactly the same as though I had: interface Eq<T> {
__eqBrand?: T
equals(other: Eq<T>): boolean
}
class A implements Eq<A>
{
equals(o: Eq<A>) {
return false
}
}
class B extends A { }
class C extends A { }
declare const b: B;
declare const c: C;
b.equals(c); // No type error We could say "there is no distinct |
Here's a simplified example: function equals(eq1: string, eq2: string): boolean {
// ...
}
declare const x: "x"
declare const y: "y"
equals(x, y) // No type error |
It grabs an
For
The idea sounds interesting. I tried it by throwing a
Overall I'm not seeing any real damage with this particular change, so hopefully that could help the original proposal here back on track. |
The conversation has diverted towards #17445.
That's because of your use of |
I feel like the rejections have held some circular logic; the current thread was closed since #17445 was yet unresolved, then that thread got closed for apparent lack of remaining use-cases... |
Reopening |
A compromise suggested by @simonbuchan seems to me like a great backward-compatible way to implement this, even though I do see the point of the syntax/semantics change that @masaeedu proposes. So, here I go, coming up with my take on two proposals for this. As I understand, motivation for implementing this feature is very simple: Both proposals do not tackle default generic parameters in any way, so their syntax and usage are to remain the same as of now. What is tackled, however, is a solution to #17445.But, IMHO, the error presented in #17445 from the very start should've beed a warning instead. # 1 - Implicitly-generic function parametersThis is, basically, what @masaeedu suggests, if I understand the issue correctly.This proposal, however, needs evaluation in terms of backward-compatibility, as I'm not sure that it is actually backward-compatible. FunctionsAll functions in TS are now generic by default and infer their parameters implicitly. Function definitions like declare function add(x: number, y: number): number;
declare function oneOfThree(x, y, z): x | y | z; are the same as declare function add<x extends number, y extends number>(x: x, y: y): number;
declare function oneOfThree<x, y, z>(x: x, y: y, z: z): x | y | z; in current TS. Writing "classic" generics explicitly, simply allows for a finer control of the function declaration by extracting repeating types from the definition into type variables. declare function addObjectKeys(
obj1: { [key: string]: number },
obj2: { [key: string]: number },
key: keyof typeof obj1 & keyof typeof obj2
): number; the declaration sure does get very tedious to read. declare function addObjectKeys<O extends { [key: string]: number }>(
obj1: O,
obj2: O,
key: keyof O
): number; which would be equivalent to writing this in current TypeScript: declare function addObjectKeys<
O extends { [key: string]: number },
obj1 extends O,
obj2 extends O,
key extends keyof O
>(
obj1: obj1,
obj2: obj2,
key: key
): number; This ensures that all functions are treated the same way, and current generic syntax becomes basically a place for defining type variables for functions. Most generics in type-heavy code (properly typed code) are used for this exact purpose - declaring type variables for later use in conditionals and stuff. So, I'd say that actually the semantics of generics do change. ClassesClasses and functions in JavaScript and TypeScript are used nearly interchangeably by many developers, which makes the possible scope of this issue wider than just functions. So, if it does touch classes, then this section becomes relevant.Using this syntax in classes is a big concern, as it looks like it would require a big rework of how the class generics are currently handled. With this syntax in mind, constructors now must have generics in order to satisfy the monomorphic conversion: // This:
class Box<T> {
constructor(public param: T) {}
}
// Should now mean this:
class Box<T> {
constructor<param extends T>(param: param) {}
} Such a constructor would not affect the instantiation of the class, and use its generic parameters only as type variables: new Box<'foo' | 'bar'>('foo');
// Generic `constructor` has no effect on the final constructor function invocation Inferring constructor parameters would work just as it does now: // This:
class Box<T> {
constructor(public param: T) {}
}
new Box('foo'); // Box<string>
// Should now mean this:
class Box<T> {
constructor<param extends T>(param: param) {}
}
new Box('foo'); // Box<string> ConclusionI'd say that it changes dramatically how TypeScript code is perceived by your average developer and is potentially a source for breaking changes, as TS has notable issues with how it currently handles generic parameters (like #14400), which seem like they need to be resolved first in order to implement this proposal correctly. Also, developers might get confused on what exactly a generic parameter now is, while also having no way to force good-old regular parameters if they need them for some reason. Oh, and I also can't imagine any workarounds for avoiding constant #17445 here. # 2 - Explicitly-generic function parameters with syntax sugarThis is, basically, what @simonbuchan suggests.Contrary to the previous one, this proposal doesn't change the semantics of generics, but rather the syntax of parameter's type definitions. FunctionsAll functions in TS stay the same, nothing changes, full backward-compatibility. However, the declare function add(x extends number, y extends number): number;
declare function keys(obj extends object): Array<keyof obj>; are just a syntax sugar for declare function add<x extends number, y extends number>(x: x, y: y): number;
declare function keys<O extends object>(obj: O): Array<keyof O>; The short equivalent produces types with the same names as the parameters they correspond to. This proposal allows for very flexible and easy-to-read generic function definitions: type Foo = { foo: number };
// this beauty:
declare function addToFoo(foo extends Foo, x: number): number;
// instead of this pile of boilerplate code:
declare function addToFoo<foo extends Foo>(foo: foo, x: number): number; Another addition of such syntax would be that the parameters can now "be referenced" as types, because the desugared type names are the exact same as the parameter names: // actually the generic parameter `f` is referenced in the return type; it just has the same name as the function parameter, which makes the whole deal easier to read
declare function map<T>(array: Array<T>, f extends (value: T) => any): Array<ReturnType<f>>;
// equivalent to:
declare function map<T, f extends (value: T) => any>(array: Array<T>, f: f): Array<ReturnType<f>>; The example is a stretch, but it gets the point across, I guess.One should actually write functions like these like this: declare function map<T, R>(array: Array<T>, f: (value: T) => R): Array<R> The addition of // even better so:
declare function map<T>(array: T[], f extends (value: T) => infer R): R[];
// equivalent to this mouthful:
declare function map<
T,
f extends (value: T) => any,
R extends f extends (value: T) => infer R ? R : never
>(array: array, f: f): R[]; This is what's supposed to happen when a compiler encounters
So, the However, old typescript definitions can't take advantage of this, as no new semantics for existing syntax are introduced. ClassesClasses and functions in JavaScript and TypeScript are used nearly interchangeably by many developers, which makes the possible scope of this issue wider than just functions. So, if it does touch classes, then this section becomes relevant.
// This should produce a compiler error!
class Box<T> {
constructor(
// here
public param extends T
) {}
}
// Because it's just syntax sugar for
class Box<T> {
constructor<param extends T>(public param: param) {}
}
// which is illegal class Box<T> {
constructor(public value: T) {}
map(f extends (value: T) => infer R): Box<R>;
}
// equivalent to:
class Box<T> {
constructor(public value: T) {}
map<f extends (value: T) => any, R extends f extends (value: T) => infer R ? R : never>(f: f): Box<R>;
} So, no breaking changes here either. ConclusionMy personal favourite is this one, as it brings no breaking changes, while also allowing developers to write generic functions much more easily, greatly increasing the probability that a developer would prefer to write a nice generic function: declare function someFunction(param extends any): param;
// desugared to:
// declare function someFunction<param>(param: param): param; instead of this: declare function someFunction(param: any); as practically no extra code is introduced in the process, #17445 can be simply worked around by applying the extended type consequently, i.e. like this: declare function equal(x extends string, y: x): boolean;
// equivalent to:
declare function equal<x extends string>(x: x, y: x): boolean;
// which is also equivalent to the classic way:
declare function equal<T extends string>(x: T, y: T): boolean;
// making the types assignable as per the conclusion in #17445
///
declare function concat(x extends string, y: x, z: x): string;
// equivalent to:
declare function concat<x extends string>(x: x, y: x, z: x): string; The case that @mhegazy mentioned
I agree with everything but the "for users" part. Even if we were to go off of examples here, there's another bit of syntactic sugar in TypeScript, to which the argument above applies just as well - parameter properties. They add a new, simple syntax for an existing functionality, just like the second proposal does. It's less verbose and more "to the point". I mean, a tiny bit of syntax sugar over generics won't magically turn TypeScript into Scala... 😅 And when it comes to maintenance, current generics syntax is not that maintainable in the first place: it's not uncommon to see even relatively small functions' declarations spanning over multiple lines just because the generic parameters for them would span over the whole screen otherwise and become practically unreadable. I tried to represent as many syntactic variations as I could while also keeping the amount of text reasonably small. I'm not very strong in writing EBNF definitions, so none are present, please, pardon me here. As for the final implementation - I'd be happy if any of the two makes it. Also, both proposals make possible the case mentioned here, just in slightly different ways. |
Every non-generic function can be expressed as generic function with a single type parameter corresponding to each argument. Explicit parameter type annotations simply correspond to
extends
constraints on the type parameters of the generic equivalent.While the title of the issue is "eliminate non-generic functions", in practice this simply gets rid of all the syntax ceremony involved in declaring generic functions. A function declaration as follows:
will be inferred as:
type F = <T1 extends any, T2 extends number>(item: T1, number: T2) => T1[]
, and the result of invoking it withrepeat("foo", 10)
is inferred asstring[]
, notany[]
.The text was updated successfully, but these errors were encountered: