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

Generic parameter should take on its default (instead of unknown) when inference of another bound type param fails #56108

Closed
5 tasks done
loucadufault opened this issue Oct 15, 2023 · 7 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@loucadufault
Copy link

πŸ” Search Terms

generic param default ignored unknown infer

βœ… Viability Checklist

  • 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 our Design Goals: https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals

⭐ Suggestion

The issue is that given a generic param with a default, where the type is bound to other sites, when the generic param type cannot be inferred at any of the inference sites, it takes on unknown rather than the default (which would be valid).

From the TS documentation for Generic Parameter Defaults:

If a default type is specified and inference cannot choose a candidate, the default type is inferred.

Which means that once the inference fails at the bound sites, the unknown is selected (as the only candidate), and then is propagated back up to the generic param, which takes on the type unknown and skips applying the default.

In the example, U fails to infer first, so U is unknown and T is inferred from that.

I would expect inference to backtrack from the failure to infer at the bound site and continue with a pass to test the default type as a candidate. Otherwise, it makes it seem as though the unknown from the failure to infer was actually received as a successfully inferred type (that would supersede the default).

Indeed, the default is selected as expected when it is for the generic param on the contained call (e.g. if the default were on U in the example).

From my understanding, assigning the default to the generic param in such cases would:
a) be valid, in all cases where the generic type param is unknown due to failing to infer (and not due to having successfully inferred the type as unknown, e.g. from an explicit unknown)
b) be less surprising than the alternative, which is the generic type parameter ignoring the default when inference fails, instead having to be explicitly specified by the caller

(Even if a) does not hold, I would expect inference to at least attempt the default as a candidate.)

πŸ“ƒ Motivating Example

function foo<T = number>(cb: (arg0: T) => void): void {}

function cbFactory<U>(): (arg0: U) => void {
    return (arg0: U) => {}
}

When called such that the type for T is bound to the type for U, but U cannot be inferred, so T and U take on the type unknown:

foo(cbFactory())

However, since the default number type for the generic param T is valid given the usage, the default should be applied to T which would propagate to U. Indeed, this would be the case if the usage manually specified the type for T:

foo<number>(cbFactory())

πŸ’» Use Cases

  1. What do you want to use this for?

My use case is a constructor where the supplied callback can either take in a key or a transformed key from another callback, and so in the absence of transformation function, the key type should be defaulted to the non-transformed type.

  1. What shortcomings exist with current approaches?

The current approach is to not rely on inference, and instead explicitly specify types for the generic params. This is tedious, as those types could instead be inferred from usage.

  1. What workarounds are you using in the meantime?

A workaround is to avoid the generic param default altogether, and instead place the default type as a union member at the inference site(s) (e.g. in the call signature).

With the example, that would look like:

function foo<T>(cb: (arg0: T | number) => void): void {}

With the same usage, T is now successfully inferred as number. This does have the limitation that where before, the type for arg0 of the cb was completely open-ended, it is now constrained to be a union with number. In some cases this is fine.

@MartinJohns
Copy link
Contributor

MartinJohns commented Oct 15, 2023

Duplicate of #16229.

TypeScript could fall back on the default type T = any when inference for T fails.

This is just not what type parameter defaults are forβ€”they very intentionally play no part in inference

@loucadufault
Copy link
Author

Duplicate of #16229.

I am not convinced. Although the reason for closure that you quoted would also apply, both issues are different in my opinion.

The comment on that issue argues why inference should result in an error rather than accept a default type that would act to suppress the issue (any). None of the discussion in that issue pertains to cases where the inference results in the type unknown, in which case a generic parameter default other than any could be selected which would be an improvement over unknown.

I would be interested in knowing if there are other reasons as to why generic parameter defaults play no part in inference, specifically in cases such as the given example.

@RyanCavanaugh
Copy link
Member

I would be interested in knowing if there are other reasons as to why generic parameter defaults play no part in inference,

All other things equal, a simpler model of inference is certainly better. The case for involving the default needs to be made on its own terms.

Let's take this example and modify it a bit

function foo<T = number>(cb: (arg0: T) => void) {
    return function(arg: T) { }
}
function cbFactory<U>(): (arg0: U) => void {
    return (arg0: U) => {}
}
const caller = foo(cbFactory());
caller("hello world");

I argue that this is a correct inference, because cbFactory produces a function that can take an unknown, and the call caller("hello world"); is therefore valid. We can make this more explicit by adding a constraint on cbFactory, obviously adding extends unknown to an unconstrained type parameter shouldn't do anything:

function foo<T = number>(cb: (arg0: T) => void) {
    return function(arg: T) { }
}
function cbFactory<U extends unknown>(): (arg0: U) => void {
    return (arg0: U) => {}
}
const caller = foo(cbFactory());
caller("hello world");

Now the argument being made here is that T = number should take precedence over the U extends unknown (including in cases where the constraint is implicit by virtue of being absent). This seems wrong, because let's say U has a more specific constraint:

function foo<T = number>(cb: (arg0: T) => void) {
    return function(arg: T) { }
}
function cbFactory<U extends string>(): (arg0: U) => void {
    return (arg0: U) => {}
}
const caller = foo(cbFactory());
caller("hello world");

Now deciding that foo(cbFactory()) means foo<number>(cbFactory()) is just wrong - it induces a type error in a correct program.

So you have to decide on at least one of these:

  • T extends unknown and T should not behave consistently, even though unknown is the implicit constraint of all type variables
  • T extends unknown and T extends string should interact differently with a type parameter default (why?)
  • There should be other epicycle to this algorithm that makes the whole thing more complex than it already is, for the sake of... what?

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Oct 16, 2023
@loucadufault
Copy link
Author

This seems wrong, because let's say U has a more specific constraint

Certainly there are cases where selecting the generic parameter default without checking it to be valid will lead to type errors.

Perhaps my original suggestion was unclear, but what I am proposing is only that the generic parameter default be considered as a candidate for the inference, meaning that (to my understanding at least) it will be selected if it is valid, satisfying all applicable constraints.

In the example you gave, number does not satisfy one of the constraints on U, so the default is therefore not a valid type for T, so inference would reject it.

This, as you mention, would require an additional pass in the inference algorithm to evaluate the validity of the default after exhausting all other candidates. And to your question, those constraints are different for the same reasons they are different in any other context, in that T extends string does actually constrain the possible type for `T (in this case coming from a type parameter default).

@RyanCavanaugh
Copy link
Member

the generic parameter default be considered as a candidate for the inference

This already happens. When there are zero other candidates, the default is chosen:

declare function fn<T = number>(arg?: T): T;

// m: number
const m = fn();

But the OP example doesn't have zero candidates; it has one candidate - the implicit constraint of U, unknown.

I'm not sure how we'd unify slotting the default into the middle of the candidate priority list -- intuitively, it should be at the bottom, and is. Making the default higher priority than it already is seems very confusing when encountered in more complex cases -- if there's another candidate, surely we should be picking that candidate over the default, otherwise the default should have been a constraint.

@fatcerberus
Copy link

I'm pretty sure the proposed behavior would make it so that

const cb = cbFactory();  // U = unknown
foo(cb);  // T = unknown

produces a different inference than

foo(cbFactory());  // U = T = string

which sounds less than ideal and potentially very confusing.

@typescript-bot
Copy link
Collaborator

This issue has been marked as "Working as Intended" 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 Oct 23, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

5 participants