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

Default values for generic parameters #49158

Closed
5 tasks done
Lakuna opened this issue May 18, 2022 · 10 comments
Closed
5 tasks done

Default values for generic parameters #49158

Lakuna opened this issue May 18, 2022 · 10 comments

Comments

@Lakuna
Copy link

Lakuna commented May 18, 2022

Suggestion

In TypeScript, there is not currently a way to supply a default value to a parameter with a generic type.

The following code throws an error: Type 'number' is not assignable to type 'T'. 'T' could be instantiated with an arbitrary type which could be unrelated to 'number'. However, it shouldn't be an issue if T is unrelated to number as, in this case, we don't need to use any of its properties as a number.

function foo<T>(bar: T = 0): T { return bar; } // Error.

If we did need T to be related to the provided default value's type, we can already specify that T must extend a type. However, even in this case, an error is thrown: Type 'ChildClass' is not assignable to type 'T'. 'ChildClass' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'ParentClass'..

class ParentClass { }

class ChildClass extends ParentClass { }

function foo<T extends ParentClass>(bar: T = new ChildClass()): T { return bar; } // Error.

In this example, foo should be able to use ParentClass or any subclass of ParentClass (such as ChildClass), so a ChildClass should be an acceptable default (and is already an acceptable value to pass in).

🔍 Search Terms

  • Generic type
  • Parameter
  • Default value

✅ Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

Parameters with generic types should be able to accept default values as long as they fit within the specified scope of the generic type.

📃 Motivating Example

I am currently writing a math library that includes several classes of matrices. The Matrix class can represent any matrix, the SquareMatrix class can only represent square matrices but has faster implementations of some methods because of it, et cetera. Each method has an out parameter which allows the output of the method to be stored in an already existing Matrix for performance purposes. Additionally, each method returns the matrix passed to the out parameter in order to make chaining methods together easier.

Since multiplying square matrices together is guaranteed to return a square matrix, the result of SquareMatrix.prototype.multiply() should be able to return a SquareMatrix (and therefore take a SquareMatrix for its out parameter). This would allow easy method chaining without ugly casting.

This is how the situation above would currently be implemented:

/*
m is the matrix to multiply by.
out is the matrix to store the result in (and the return value).
Matrix.prototype.multiply(m: Matrix, out: Matrix = this): Matrix
*/

const a = new SquareMatrix();
const b = new SquareMatrix();
const c = new SquareMatrix();

const d = (a.multiply(b) as SquareMatrix).multiply(c) as SquareMatrix;

This is how the situation above could be implemented if this change is made:

/*
m is the matrix to multiply by.
out is the matrix to store the result in (and the return value).
Matrix.prototype.multiply<T extends Matrix>(m: T, out: T = this): T
*/

const a = new SquareMatrix();
const b = new SquareMatrix();
const c = new SquareMatrix();

const d = a.multiply(b).multiply(c);

💻 Use Cases

My current use case is described in the motivating example section above, but this is a feature that I can see being useful in other cases as well. In the meantime, casting the parameter's default value to the generic type (T in all of the examples above) allows this to work; however, it shouldn't be necessary to perform a cast.

@fatcerberus
Copy link

In this example, foo should be able to use ParentClass or any subclass of ParentClass (such as ChildClass), so a ChildClass should be an acceptable default (and is already an acceptable value to pass in).

Not necessarily. If T extends Animal, you can't assign a Cat as a default, because someone might call it with T = Dog. Hence the error, because the function is required to work for all possible types T.

@MartinJohns
Copy link
Contributor

MartinJohns commented May 18, 2022

In the meantime, casting the parameter's default value to the generic type (T in all of the examples above) allows this to work; however, it shouldn't be necessary to perform a cast as this is already a feature in other languages (such as Java and C#).

The feature you describe doesn't exist in C#. You can't have non-const default arguments for parameters, and C# doesn't have anything like union types or literal types.

@jcalz
Copy link
Contributor

jcalz commented May 18, 2022

The situation you're describing is definitely something people run into (maybe I'll come back later and drop links here to Stack Overflow questions about it)... but it's not really feasible to have the compiler simply allow the default value like you're suggesting; type parameters on functions are specified by the caller, not the implementer, and nothing stops a confused/malicious/unhinged caller from manually specifying some other subtype of your constraint:

class OtherChildClass extends ParentClass { prop = "hello"; }
foo<OtherChildClass>().prop.toUpperCase() // no compiler error, but explodes at runtime

Right now there's no great way to deal with this. The options I see:

Just assert that no caller is going to do the crazy thing, which means using that type assertion you don't like (aside: you're calling this "casting" but that ambiguous term often makes people think of runtime type coercion, so it's best avoided in TypeScript). You can also assign a default for the type parameter itself:

function foo<T extends ParentClass = ChildClass>(bar: T = new ChildClass() as T): T { return bar; }

const cc = foo(); // const cc: ChildClass

Or, you can actually prevent such calls by giving the function multiple overloaded call signatures that correspond to the desired behavior for when the function is called with or without an argument:

function foo(): ChildClass;
function foo<T extends ParentClass>(bar: T): T;
function foo(bar: ParentClass = new ChildClass()) {
  return bar;
}

const cc = foo(); // const cc: ChildClass

You can even do both at once:

function foo(): ChildClass;
function foo<T extends ParentClass>(bar: T): T;
function foo<T extends ParentClass>(bar: T = new ChildClass() as T) {
  return bar;
}

const cc = foo(); // const cc: ChildClass

This last version is as type safe and usable as we can get right now; it prevents callers from specifying a type parameter without a corresponding function argument, and the body of the function has bar being of type T as desired. But it's both cumbersome and error-prone (it relies on developers actually writing out the right call signatures and the right assertions).


It would be great if there were a convenient way to get the compiler to do this for us. I want to say "if the caller does not pass bar, then T will be specified as ChildClass". Like

function foo<T extends ParentClass>(bar: T = new ChildClass() default T = ChildClass) {
  return bar
}

except with some better syntax devised by some smarter person.

Playground link to code

@fatcerberus
Copy link

It would be great if there were a convenient way to get the compiler to do this for us. I want to say "if the caller does not pass bar, then T will be specified as ChildClass".

Yeah, this is really the crux of it: there's an impedance mismatch because generic inference is always based on the call site and whether the default value is used for a parameter is decided only after that, by which point T is already fixed. Supplying a default for the generic somewhat mitigates this, but doesn't solve the underlying impedance mismatch. Ideally, TS would be able to infer the generic directly from a defaulted parameter if that argument isn't present, but this isn't possible today (and still runs into the problem of, what if the caller directly supplies an incompatible type?)

@Lakuna
Copy link
Author

Lakuna commented May 18, 2022

Gotcha; thanks for all of the feedback.

Also, my bad on the note about C# at the end - I could have sworn I was able to do that. I updated the original post to remove it.

@Lakuna Lakuna closed this as completed May 18, 2022
@yelhouti
Copy link

@Lakuna could you please reopen the issue ? I understand that the current version of typescript doesn't allow this, but I really think it should.
Keeping this open also allows people to up cote it so it can get implemented in the future.

@Lakuna
Copy link
Author

Lakuna commented Oct 25, 2022

@Lakuna could you please reopen the issue ? I understand that the current version of typescript doesn't allow this, but I really think it should.
Keeping this open also allows people to up cote it so it can get implemented in the future.

No; this suggestion does not fit in TypeScript for the reasons described in the comments above.

@yelhouti
Copy link

@Lakuna I am not talking about your suggestion, but more about having a way solve the use-case.

I am implementing something like:

export function mappedBy<T, U>(models: T[], mapper?: (_: T) => U, ...fields: (NestedKeyOf T)[]): MappedByResult<T, U>

which for:

models = [{
  id: 1,
  value: 1
,{
  id: 2,
  value: 2
}]
mappedBy(models, x => x, 'id') 

should return {1: {
id: 1,
value: 1
,2: {
id: 2,
value: 2
}}

But I can't do it because of the nested stuff with the suggested solutions...

@jcalz
Copy link
Contributor

jcalz commented Jun 22, 2023

I am also in favor of reopening, or maybe I'll create a new one that says "this is a problem people have, is there any way to improve it" rather than suggesting a particular approach.

@movahhedi
Copy link

How i handled my case:

type IErrorStatus = 500 | 501 | 503 | 507;

function RespondFail<THttpStatus extends IErrorStatus = 500>(httpStatus?: THttpStatus) {
	const httpStatusOrDefault = httpStatus || 500;

	return httpStatusOrDefault as THttpStatus,
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants