Open masaeedu opened 7 years ago
Every non-generic function can be expressed as generic function with a single type parameter corresponding to each argument.
That is not correct. For example, in a function taking two parameters of type Foo
, those two parameters have identical types and are assignable to each other. But if you substitute two type parameters (each constrained to Foo
), neither is assignable to the other because they may actually represent distinct and different types.
@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 a
and b
are assignable to references of type Foo
. Additionally, since the proposal is to eliminate non-generic functions, and not generic functions, it is still possible to express <T1 extends Foo>(x: T1, y: T1)
.
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 function repeat(item, n: number) => Array(n).fill(item)
, which uses <>
, is extremely unwieldy and nests poorly.
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 <>
, it's an explicit forall
which doesn't take up more space than in other languages. To the contrary, it adds clarity. On numerous occasions people have said "AHA" when I've translated signatures to the <>
style.
Unfortunately, I don't see how polymorphic types could be assigned to parameters without doing the actual inference
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 const id = a => a;
is ascribed the type <T>(a: T) => T
instead of the type (a: any) => any
, and so at a callsite my application of id(10)
results in proper inference of number
for T
.
Or do you mean only in the cases when the type parameters need no constraints?
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 ({}
?). E.g. if I write the function const f = (a: A) => Object.assign({}, a, { foo: a.aStuff() })
, passing in a B extends A
at the callsite would result in the more useful result B & { foo: ... }
.
Also, I don't completely agree ...
The explicit forall
can add clarity in some cases, so it is good to still have the ability to use <>
, but there is a reason Haskell has the lowercase letter shorthand for declaring type parameters. If you had to explicitly write a forall
quantified type definition for every tiny function you'd never get anything done.
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 type Foo = ...; const foo: Foo = ... /* no explicit types here */
.
You might find https://github.com/Microsoft/TypeScript/issues/15114 interesting. TypeScript's unconstrained unions (i.e. unions between arbitrary types, not a predefined set of data
constructors), intersections, overloads, mutability, subtyping, literals, etc. makes type reconstruction quite challenging. I'm not saying it's completely impossible, but it would be extremely hard to do right with the current state of TS. Especially without a proper unification solver.
Explicit forall
is not so uncommon. E.g. PureScript has made it mandatory.
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 (https://github.com/Microsoft/TypeScript/issues/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 ::
a special syntax that activates "shorthand" type definitions? I'd personally be happy with that too, I'm just finding life very difficult when I define curried functions of several arguments and I need to keep performing the magic <T extends Foo>(foo: T)
ritual to prevent losing type information at each step. The point of parameter type annotations (for me at least) is to constrain the types of input, not to throw them away.
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 <T extends Foo>(foo: T)
retains better type fidelity than (foo: Foo)
.
Is the
::
a special syntax that activates "shorthand" type definitions?
Yes, that was my intention.
The point of parameter type annotations (for me at least) is to constrain the types of input, not to throw them away.
Unfortunately TypeScript has some difficulties with parametric polymorphism, but on the upside things have improved significantly.
I'll look at #15114, although I don't see how that is relevant to this issue.
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. https://github.com/Microsoft/TypeScript/issues/15114#issuecomment-293393165.
@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 parser.ts
; checker.ts
would remain totally unchanged.
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 any
. The parameters that already have a (non polymorphic) type would be converted to bounds.
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 X
is being consumed, a parameter ascribed the polymorphic type T extends X
will do just as well.
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. x => Math.sqrt(x)
is treated as <T1 extends number>(x: T1) => Math.sqrt(x)
and so forth.
It is true that within a function expression of the form function f(x: Foo)
, it now becomes impossible to assign to x
. However, it should be noted that in JS, assigning to x
isn't really particularly useful, unless you feel like being stingy with local variables. JS has no concept of "pass by reference" or "out params", so reassignment of parameters is guaranteed to have no impact on the caller's scope. For this reason I think requiring anyone reassigning their parameters to use as any
is an acceptable tradeoff.
If this modest break with backwards compatibility is deemed unacceptable, we have two alternatives that maintain total backwards compatibility:
function<T extends Foo>
and function<Foo extends T>
, which, as a corollary, gives you function<T is Foo>
. Then this proposal, except with T is AnnotatedType
instead of T extends AnnotatedType
wherever an annotation already exists. The end result is that monomorphically annotated parameters retain their monomorphic type, but we still get rid of the distinction between "generic" and "non-generic" functions.On the other hand, it's a step in a more strict and sound direction.
Actually I would argue that noImplicitAny
is a better way, and good news is we already have it. 😁
@kitsonk The purpose is to make declaring polymorphic functions less verbose. E.g. this:
const pluck = <TProp extends string>(p: TProp) => <T extends {[k in TProp]}>(x: T) => x[p]
Would now be expressed as:
const pluck = (p: string) => (x: {[k in typeof p]}) => x[p]
I don't see how noImplicitAny
helps you with this.
Perhaps a compromise?
function repeat(item extends any, n extends number) => Array(n).fill(item)
const pluck = (p extends string) => (x extends { [k in typeof p] }) => x[p];
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 T
and U
to not be comparable when both are subtypes of string
(which does support ==
), but I see your point.
If they are generic, they are two distinct types, and not the same one. e.g. call Generic<"foo", "bar">
these are not comparable.
also see some related discussions in https://github.com/Microsoft/TypeScript/issues/17926 and https://github.com/Microsoft/TypeScript/issues/11218
@mhegazy That's well and good, but they are both still instances of at least string
, and should be comparable as such. Right now I can do (a as string) === b
, but this explicit cast is redundant; the compiler is already aware that both a
and b
are string
.
Putting it another way, if types are sets, a
and b
are values selected from two unknown subsets of string
. Thus any operations that demand strings (such as ===(a: string, b: string): boolean
) should be satisfied with T
s and U
s.
@masaeedu: yeah, it does feel like the Generic
example is a more fundamental problem about these comparisons, rather than about the use of generics here. e.g. const one = 1; declare let num: number; one == num
should be legitimate in my view, rather than raising a compiler error unless num
is guaranteed to be within 1
.
It does feel hard to get right though -- I get that they wanted to error on num as number == str as string
. The question feels like at what level to compare things. e.g. in your a == b
example, the compiler would need to know whether to compare the types as they are (distinct generics), widened (string
), or something even higher (like any
, at which point the check becomes moot again). Say [1,2,3]
could be widened to a few different things, though for comparator cases like this just number[]
(/ Array<{}>
?) may do.
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 const
(https://github.com/Microsoft/TypeScript/pull/17785#issuecomment-322973594). As noted here, that mostly seems to affect functions like <T>(a: T, b: T)
. When for that PR I tried the change on TS's test suite, this meant I had to annotate a dozen places like that throughout, essentially the test *.ts
files at the bottom of https://github.com/tycho01/TypeScript/commit/30c9beb6f012c5990b215deefe81700b8c7e3599.
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.
The concerns raised are definitely relevant, but similar reasoning could have been used to dismiss strictNullChecks
for breaking old code.
@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 B#equals(c: C)
operation, but this doesn't matter, because TypeScript is able to determine some set of supertypes from the operands for which the operation is defined. Similar logic is needed for ===
and other operators.
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
@masaeedu:
TypeScript is able to determine some set of supertypes from the operands for which the operation is defined.
It grabs an equals
definition with A
(not B
) for T
, hence c
matches.
I don't know if operators are given special treatment in TypeScript
For ==
see checkBinaryLikeExpression
in checker.ts
(too big to view on Github) -- for EqualsEqualsToken
it checks isTypeEqualityComparableTo
in both directions for limited-widened (getBaseTypeOfLiteralType
) versions of the operand types.
Similar logic is needed for === and other operators.
The idea sounds interesting. I tried it by throwing a getApparentType
on top, which makes Generic
pass, see https://github.com/tycho01/TypeScript/commit/36d39fb118f16e1759d094edb8f72a871c6c3889.
Other affected tests (https://github.com/tycho01/TypeScript/commit/7eb7f499cacf0df5e9cdacd4510715b1a54008b5):
0 == 1
now allowedT == U
now allowed unless their constraints indicate we should believe otherwiseOperator '==' cannot be applied to types 'number' and 'string'
somehow switched to capitalized types like Operator '==' cannot be applied to types 'Number' and 'String'
. Not sure I get this one.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 https://github.com/Microsoft/TypeScript/issues/17445.
@tycho01
existing errors like Operator '==' cannot be applied to types 'number' and 'string' somehow switched to capitalized types like Operator '==' cannot be applied to types 'Number' and 'String'. Not sure I get this one.
That's because of your use of getApparentType
.
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:\ Give developers a more simple and a less verbose way of defining generic functions, which are superior to regular functions in most use-cases. This allows for more user-friendly generics, which are easier to read, edit, and reason about.
Both proposals do not tackle default generic parameters in any way, so their syntax and usage are to remain the same as of now.
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.\ Any questions/additions are welcome as it's my first try ever on writing a proposal here, and I'm willing to continue on completing these proposals' definitions.
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[]
.