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

Eliminate non-generic functions #17428

Open
masaeedu opened this issue Jul 26, 2017 · 28 comments
Open

Eliminate non-generic functions #17428

masaeedu opened this issue Jul 26, 2017 · 28 comments
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript

Comments

@masaeedu
Copy link
Contributor

masaeedu commented Jul 26, 2017

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:

function repeat(item, n: number) => Array(n).fill(item)

will be inferred as: type F = <T1 extends any, T2 extends number>(item: T1, number: T2) => T1[], and the result of invoking it with repeat("foo", 10) is inferred as string[], not any[].

@ahejlsberg
Copy link
Member

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.

@masaeedu
Copy link
Contributor Author

@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).

@gcnew
Copy link
Contributor

gcnew commented Jul 26, 2017

Is this an ask for non-local type inference in disguise? I don't see why already annotated parameters should be synthetically made polymorphic.

@masaeedu
Copy link
Contributor Author

@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.

@gcnew
Copy link
Contributor

gcnew commented Jul 26, 2017

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.

@masaeedu
Copy link
Contributor Author

masaeedu commented Jul 26, 2017

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 */.

@gcnew
Copy link
Contributor

gcnew commented Jul 27, 2017

You might find #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 (#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.

@masaeedu
Copy link
Contributor Author

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).

@gcnew
Copy link
Contributor

gcnew commented Jul 27, 2017

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. #15114 (comment).

@masaeedu
Copy link
Contributor Author

@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.

@gcnew
Copy link
Contributor

gcnew commented Jul 29, 2017

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.

@masaeedu
Copy link
Contributor Author

masaeedu commented Jul 31, 2017

@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:

  • Only apply this treatment to parameters with no explicit type annotations, which is basically parameters: presume generics over any #14078, and comes with some awkward tradeoffs
  • Improve the expressiveness of bounds on parametric polymorphism, so that it is possible to express both 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.

@kitsonk
Copy link
Contributor

kitsonk commented Jul 31, 2017

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. 😁

@masaeedu
Copy link
Contributor Author

@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.

@simonbuchan
Copy link

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];

@mhegazy
Copy link
Contributor

mhegazy commented Aug 21, 2017

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 mhegazy added the Out of Scope This idea sits outside of the TypeScript language design constraints label Aug 21, 2017
@mhegazy mhegazy closed this as completed Aug 21, 2017
@masaeedu
Copy link
Contributor Author

@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.

@mhegazy
Copy link
Contributor

mhegazy commented Aug 21, 2017

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 #17926 and #11218

@masaeedu
Copy link
Contributor Author

@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.

@masaeedu
Copy link
Contributor Author

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 Ts and Us.

@KiaraGrouwstra
Copy link
Contributor

KiaraGrouwstra commented Sep 15, 2017

@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 (#17785 (comment)). 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 KiaraGrouwstra@30c9beb.

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.

@masaeedu
Copy link
Contributor Author

masaeedu commented Sep 15, 2017

@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.

@masaeedu
Copy link
Contributor Author

masaeedu commented Sep 15, 2017

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

@KiaraGrouwstra
Copy link
Contributor

@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 KiaraGrouwstra@36d39fb.
Other affected tests (KiaraGrouwstra@7eb7f49):

  • 0 == 1 now allowed
  • T == U now allowed unless their constraints indicate we should believe otherwise
  • 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.

Overall I'm not seeing any real damage with this particular change, so hopefully that could help the original proposal here back on track.

@gcnew
Copy link
Contributor

gcnew commented Sep 16, 2017

The conversation has diverted towards #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.

@KiaraGrouwstra
Copy link
Contributor

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...

@RyanCavanaugh RyanCavanaugh added Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript labels Sep 25, 2017
@RyanCavanaugh RyanCavanaugh removed the Out of Scope This idea sits outside of the TypeScript language design constraints label Sep 25, 2017
@RyanCavanaugh
Copy link
Member

Reopening

@Raiondesu
Copy link

Raiondesu commented Sep 23, 2020

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.

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.
This allows to still inform the user that they might be doing something fishy, while also allowing a perfectly valid operation.

# 1 - Implicitly-generic function parameters

This 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.

Functions

All 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.
The short equivalent produces types with the same names as the parameters they correspond to.
Another addition of this would be that the parameters can now "be referenced" as types, because the desugared type names are the exact same as the parameter names.

Writing "classic" generics explicitly, simply allows for a finer control of the function declaration by extracting repeating types from the definition into type variables.
For example, in this scenario:

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.
So we need a way to extract the repeated type into a "type variable" of sorts, just like how we would do in current TS:

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.
But I can only see positive effects in this.

Classes

Classes 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>

Conclusion

I'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 sugar

This 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.

Functions

All functions in TS stay the same, nothing changes, full backward-compatibility.

However, the extends keyword is now allowed after any function parameter and infer is allowed after extends.
So, function definitions like

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 infer into the mix is not necessary, but makes the new syntax much more useful... and just so gorgeous:

// 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 infer after the extends in parameters:

  1. Extract the extends clause into generic parameters with the same type name as the parameter name, while replacing it with : %parameterName% in function parameters.
    declare function map<T, f extends (value: T) => infer R>(array: T[], f: f): R[];
  2. Replace infer % with any in the extracted clause.
    declare function map<T, f extends (value: T) => any>(array: T[], f: f): R[];
  3. Add another type parameter definition right after the extracted clause, name new parameter as % from infer %, where % is a placeholder for the parameter name.
    declare function map<T, f extends (value: T) => any, R>(array: T[], f: f): R[];
  4. Make the new parameter infer a value from the original clause via a conditional type.
    1. Add extends after the type parameter name.
      declare function map<T, f extends (value: T) => any, R extends >(array: T[], f: f): R[];
    2. Copy the original extends clause with the original parameter name after the previously added extends.
      declare function map<T, f extends (value: T) => any, R extends f extends (value: T) => infer R>(array: T[], f: f): R[];
    3. Add ? % : never, where % is a placeholder for the infer % parameter name.
      declare function map<T, f extends (value: T) => any, R extends f extends (value: T) => infer R ? R : never>(array: T[], f: f): R[];
  5. In case of unsuccessful result of these steps, resolve the type to any.

So, the infer keyword in parameters is a shorthand for conditional types in the same way as extends is a shorthand for generic types.
And in case a more complicated conditioning is needed, it's always possible to revert back to using "classic" generic syntax.

However, old typescript definitions can't take advantage of this, as no new semantics for existing syntax are introduced.
But, as a nice bonus, this proposal brings zero breaking changes!

Classes

Classes 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.

extends in constructor parameters is disallowed just like generics, but are still allowed in methods and properties.

// 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.

Conclusion

My 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,
making this proposal's syntax greatly superior to simple param: type definitions.
Adding to this is the fact that generic function parameters and regular function parameters are still very distinctive, which makes the new syntax much easier to adopt.

#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

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.

I agree with everything but the "for users" part.
TypeScript's learning curve seems to me to be quite linear anyway, even with all the syntactic load that only adds to the learning curve of JavaScript.

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".
So if it's allowed to exist, then why something like this shouldn't be also?

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.
And I'm still yet to mention what happens when people try to give meaningful readable names to generic parameters, instead of reciting the alphabet...

Warning: typical production code imminent...

image


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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

9 participants