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

[Feature request] Support generic type default values in functions #56315

Closed
alesmenzel opened this issue Nov 5, 2023 · 16 comments
Closed

[Feature request] Support generic type default values in functions #56315

alesmenzel opened this issue Nov 5, 2023 · 16 comments
Labels
Duplicate An existing issue was already created

Comments

@alesmenzel
Copy link

🔎 Search Terms

"generics default value", "generics default"

🕗 Version & Regression Information

  • This is a crash No
  • This changed between versions - it works the same on all versions in ts playground
  • This changed in commit or PR -
  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about generics default value
  • I was unable to test this on prior versions because -

⏯ Playground Link

https://www.typescriptlang.org/play?ts=5.3.0-beta#code/PTAEhlyUBUAsFNQHM4Ds4CcCWBjUA1AQwBsBXBAZxgHsSiATUAIzmyoFsEByAz0Ad3gpQAF3igAbsTKhM5UCirCkmcagBQcAB4AHKuiUAzEimzDMVIXSoBldnFGYUiADyFSCLcNR053XgB9QTkZeAF4gngA+AApJdwB+AC58KQRwvwBKZLdpAG81AEh0exJ0ITiyNQBfNTVWFHIlL0aAfQBGUHCrWw4HJ2iMtRBQEdGAPXihsEBQcmgxCoRZGRQDDDgGAl8eABomEiUoa1ArOHIUJWIiKj4RKlBi4VKhP1ADdHYRMUYqOgBPUCoBk+CCMJjMFjqFkaIlOwhaACZOscbHY+ohosY6HADI51oNhqMRhMphA5ggFjI5I5VsUNlsAkEQgA6EmQayObAIQ6gerkTBY9ByABECySKXcQtAm1AItS2VSoECmOxuLoktEqGB91OtDMThEv20FGotAYzC1WIMBF1BqNoAAtJEIpwmUA

💻 Code

// ❌ The generic Value should become 'a' when the value is not given
export function doSomething<Value extends 'a' | 'b' = 'a'>(value?: Value = 'a'): Value {
	return value
}

const test_1 = doSomething()
//      ^?
// ✅ The value is infered as 'a', but TS doesnt allow to return 'a' from the body of the function
const test_2 = doSomething(undefined)
//      ^?
// ❌ The value is infered as 'a' | 'b'.
// ❌ Since TS considers "value?: Value" as "value: Value | undefined" then the resulting type should be the default type -> 'a'.

🙁 Actual behavior

Usage of default parameters with generics is forbidden.

🙂 Expected behavior

We are able to use default parameters with generics. The generic type should use the default type whenever the type is missing or is undefined unless the generic includes undefined in its own type.

Additional information about the issue

No response

@MartinJohns
Copy link
Contributor

MartinJohns commented Nov 5, 2023

This is working as intended. The value "a" might not be assignable to the type Value, because the type of Value could also be "b". It's perfectly valid to call your function like this: doSomething<"b">(undefined), returning "a" here would be an error

If you want to always allow "a", then you can use the type Value | "a". Alternatively a overload could be an option for you.

@fatcerberus
Copy link

So the problem with default values for generic parameters, in general, is that you can write

const test_3 = doSomething<"b">();

which returns "a" at runtime but is typed as "b"

@jcalz
Copy link
Contributor

jcalz commented Nov 5, 2023

Duplicate of #49158 but that one is closed and I think it would be useful to have a feature request for some way to have default function parameters involved in type inference. People run into this and the current approaches are all pretty clunky.

@alesmenzel
Copy link
Author

alesmenzel commented Nov 5, 2023

This is working as intended. The value "a" might not be assignable to the type Value, because the type of Value could also be "b". It's perfectly valid to call your function like this: doSomething<"b">(undefined), returning "a" here would be an error

If you want to always allow "a", then you can use the type Value | "a". Alternatively a overload could be an option for you.

@jcalz @MartinJohns
It would be great to add this valuable information in the TS handbook, right now there is only a short section that we can use default generic type, but without a simple usage like this.

But honestly, working with generics and default values is still kind of a hit-or-miss, see this updated example based on your suggestions. Now the types inside of the functions are correct ✅, but the outside types are not ❌ 😢 .

export function doSomething<Value extends 'a' | 'b' = 'a'>(value: Value | 'a' = 'a'): Value | 'a' {
	return value
}

const test_2 = doSomething(undefined)
//      ^?
// ❌ The value is infered as 'a' | 'b'.

const test_3 = doSomething('b')
//      ^?
// ❌ The value is infered as 'a' | 'b'.

Playground for the updated version

@alesmenzel
Copy link
Author

@MartinJohns
We should treat doSomething<"b">(); as unsafe type casting and not cater for that use case in my opinion.

@jcalz
Copy link
Contributor

jcalz commented Nov 5, 2023

There’s no typecasting there. That’s just how generic type argument specification works. I think this should be a feature request for some way to support the underlying use case, and not a request to redefine the way generic type argument specification works. That is, if you want the doSomething<"b">() call to be rejected, you’d need to change the doSomething() call signature.

@alesmenzel
Copy link
Author

@jcalz But doSomething<"b">() is extremely unsafe, please see an example below, where you force the generic to be of type 'b', but the return type is 'a' | 'b' and what is even worse is that the actual real return type of the runtime of the function is 'a' because you are not passing any value to the function.

export function doSomething<Value extends 'a' | 'b' = 'a'>(value: Value | 'a' = 'a'): Value | 'a' {
	return value
}

const x = doSomething<"b">()
//    ^?
// 'a' | 'b'
// runtime return value is 'a'

@jcalz
Copy link
Contributor

jcalz commented Nov 5, 2023

It’s unsafe because you didn’t implement the function properly and it gave you the requisite error about it. The call signature allows the call, and your implementation is wrong. If you want to disallow the call then you have to change the call signature.

We can all agree that it’s unsafe to drive a car without wearing the seat belt, but you’re saying the car shouldn’t move when you step on the accelerator in such a situation. That’s not how it works: the car still moves but there’s a warning light on the dashboard about your seat belt.

@alesmenzel
Copy link
Author

alesmenzel commented Nov 5, 2023

@jcalz Can you please provide the correct types for that function?

Here is the javascript version:

export function doSomething(value = 'a') {
	return value
}

@jcalz
Copy link
Contributor

jcalz commented Nov 5, 2023

Currently the best you can do looks like this:

function doSomething<V extends "a" | "b">(value: V): V;
function doSomething(): "a";
function doSomething(value: "a" | "b" = "a") {
    return value
}

const a = doSomething(); // "a"
const a2 = doSomething("a"); // "a"
const a3 = doSomething<"a">("a"); // "a"

const b = doSomething("b"); // "b"
const b2 = doSomething<"b">("b"); // "b"

doSomething<"b">(); // not allowed
doSomething<"a">(); // also not allowed

Playground link

This is all laid out in #49158. We need a feature request here, not a bug report. TS is behaving exactly as intended and changing it to match reasonable but incorrect expectations isn't going to work here. But it would be great to have a feature that handled this use case. In #49158 I half-heartedly suggested

// not valid TS, don't try this:
function doSomething<V extends "a" | "b">(value: V = "a" default T = "a"): V {
    return value;
}

but the exact syntax is less important than the feature.

@alesmenzel
Copy link
Author

@jcalz Thanks, so currently there is no way to represent it. The only way is to use overloads, which brings another set of issues that I wanted to avoid (e.g. only one of the overloads is ever inferred when used in utility types like Parameters<typeof doSomething> or when you create wrapper function with the same signature, you need to duplicate the overloads all over the place).

@alesmenzel alesmenzel changed the title Generic type default values in functions [Feature request] Support generic type default values in functions Nov 5, 2023
@MartinJohns
Copy link
Contributor

MartinJohns commented Nov 5, 2023

We should treat doSomething<"b">(); as unsafe type casting and not cater for that use case in my opinion.

But it's not unsafe type casting at all.

Also take this example:

declare const val: "b" | undefined
doSomething(val)

The inferred generic type will obviously be "b", but according to your implementation it will return "a" in case of undefined. Unless you want to argue that it should infer the type "b" | "a", but then you're no better than using Value | "a".

@alesmenzel
Copy link
Author

alesmenzel commented Nov 5, 2023

@MartinJohns

I want to represent

given Value of type 'a' or 'b' or missing (= undefined), return the same type 'a' or 'b' or if no value is given, then return 'a'

with a TS generic. No overloads (due to limitations with utility types and auto-complete stops working when you start writing for example a prop. name and it yet doesn't match any full property, because no overload match was found, whereas with generic there is only one "source of truth" so TS always tries to auto-complete the property name), no unions (because TS has some limit on complex union types, then it fails to comprehend them).

This is probably closest to actually return correct type output, but uses a lots of anys and it doesn't work with more complex types as TS bails out on Type instantiation is excessively deep and possibly infinite.ts (2589) when used in a real project.

export function doSomething<Value extends 'a' | 'b' = 'a'>(value: Value = 'a' as any): Value extends undefined ? Exclude<Value, undefined> : Value {
	return value as any
}

const x = doSomething<"b">()
const x_0 = doSomething<"b">()
const x_1 = doSomething('b' as 'b' | undefined)
// ❌ Here it infers 'b' as the generic, but I don't think that's correct, since the input can be also undefined -> it should infer as 'a' | 'b' because of the default
// Same as it does with `doSomething(undefined)` or 
const x_2 = doSomething<"a">()
const x_3 = doSomething('a')
const x_4 = doSomething('b')
const x_5 = doSomething()
const x_6 = doSomething(undefined)
// ❌ Here as well, we are not passing any value, so the default should be inferred in my opinion

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. labels Nov 6, 2023
@CassWindred
Copy link

I've been bumping into this a lot - it seems the primary issue here is that typecasting a function is always prioritised over an inferred type. Thus, you can never have a default value that might conflict with a defined typecasting, even if the intended use of a function is for it to use an inferred type from its values.

This results in worse code everywhere a default parameter is needed, either through verbose overloads, judicious use of any, as casts, etc.

Would it be possible, then, to mark a generic type as inferable only, that way we can assign a default that matches a generic constraint without worrying that the caller might specify a conflicting type.

e.g

function Example<inferred T extends Bool>(a: T = true): T {return A}

const x = Example() //type: true
const x0 = Example(false) //type: false
const x1 Example<true>() //type error: cant declare an inferred generic type
const x2 Example<false>() //type error: cant declare an inferred generic type
const x3 = Example<false>(false) //type error: cant declare an inferred generic type

Alternatively, a function could treat the default as a given parameter at the call-site if not specified, raising a type error either on the parameters or on the cast type if it conflicts with that default parameter.

const x = Example() //type: true
const x0 = Example(false) //type: false
const x1 Example<true>() //type: true
const x2 Example<false>() //type error: default value true cannot be assigned to type false
const x3 = Example<false>(false) //type: false

Basically, instead of preventing an extremely common pattern because the caller might provide a type that the default cannot be assigned to, move the type error to the call-site if they do. Right now one specific invalid case is invalidating the whole set, even though the vast majority of scenarios would otherwise be perfectly fine.

@RyanCavanaugh RyanCavanaugh added Duplicate An existing issue was already created and removed Suggestion An idea for TypeScript Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. labels Jun 24, 2024
@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Jun 24, 2024

Going to track this at #58977

@typescript-bot
Copy link
Collaborator

This issue has been marked as "Duplicate" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@typescript-bot typescript-bot closed this as not planned Won't fix, can't repro, duplicate, stale Jun 27, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Duplicate An existing issue was already created
Projects
None yet
Development

No branches or pull requests

7 participants