Skip to content

Inconsistent Parameter Type Inference for Functions with Default Values #60222

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

Closed
supitsdu opened this issue Oct 14, 2024 · 5 comments
Closed
Labels
Not a Defect This behavior is one of several equally-correct options

Comments

@supitsdu
Copy link

🔎 Search Terms

"parameter type inference" "default parameters" "function type inference issue" "any type" "inconsistent type inference" "default value"

🕗 Version & Regression Information

  • When did you start seeing this bug occur?

I started observing this behavior in TypeScript version 5.5.4, and it has continued in the latest stable release 5.6.3.

  • Have you tried using the nightly version of TypeScript?

Yes, I have tested the latest nightly version (5.7.0-dev.20241014), and the issue persists.

  • Bisecting the issue using every-ts:

I have not yet bisected the issue with every-ts, but I will consider doing this to identify the specific change.

  • Using the Playground:

I have tested the issue in the TypeScript Playground with versions back to 3.3, and the problem is reproducible across multiple versions.

⏯ Playground Link

https://www.typescriptlang.org/play/?ts=5.6.3#code/PTAEBUAsFNQMQK4DsDGAXAlgeyQZ1AAZqQa4GgDW0AngO5YBOAJqCjmtAB5oBQa1AB1hwkAYXZc0oALygA3j1BLQuLAgYpoALlAAlaG2YAeXGgYYkAcwA0oAIZJqAPh4BfHjxAQYoAGbJ0bCRQJmhfCwxMHD5BYSQjcBl4MQluWwAFUEloJCZ8B2oAbQBdJIKS210kgDcsDCYnJIAKYlIdcFsAOm6BOwY7AFtcHXSAShlG3Q9+IVAAQUcRJP1DJhMzCxtklxjZgHkBKLwjOCzuHLz5xaRG2QVle0CcXAB+HTg3D39UI9ABBiwln6AwSZw4uXwC2oIicTSwhyCw1AByOuASTnGclA7k8YD26lAEUwdgANvY0BwBodQGgsI8SNBqrB-vDoAwabFCUhfGycpoeCygYMmvdlHYnngdKKHkpfFgsE0BEkAEyY1y2LwASTwHDsLCwvi5PIY5isoAA5EgEAMAEZs822cCxADKKHM1PhaHwcvZ5oK5s681Y6mwCHwKEgdU0tgsoWgTE6ihlSmguAEip0VttbJVao1YAAopwBCSMChIhyBJt7CSSVhaPhWo26fQGBR7PgLODQgmk8mbVgbU0kVm7Qw1aAvFBSKAcLBWf0OPhcAN5cQSdRbAwwiSDJgzcRYFwhOh46A7ZA7NVsOyDRAXW6MIdEw93K5RjwgA

💻 Code

// The Functions `this` keyword context
type FnContext = {
    source: Record<string, any>
}

// The function definition
type Fn<T = FnContext, P extends any[] = any[], R = void> = (this: T, ...params: P) => R

type AnyFn = Record<string, Fn>

type Options<F extends AnyFn> = {
    actions?: F
}

function program<T extends AnyFn>(options: Options<T>) { }

// Our initial attempt to achieve proper type inference
program({
    actions: {
        foo(p = 2) {}, // Instead of inferring 'number', TypeScript opts for 'any'. A curious choice, indeed.
        esp(p: number = 2) {}, // Explicit typing allows this to work as intended.
        bob(s: number) {} // This one operates smoothly, reflecting the expected behavior of TypeScript.
    }
})

🙁 Actual behavior

The foo function is inferred as (method) foo(this: FnContext, p?: any): void, which is unexpected and undesired.
The esp and bob functions behave as expected.

🙂 Expected behavior

  1. The foo function should have its p parameter type inferred as number, due to the default value provided.
  2. The esp function behaves correctly, with p inferred as number.
  3. The bob function should remain unaffected and should correctly infer s as number.

Additional information about the issue

I have attempted various approaches, including changing the default parameter type to an empty tuple (TypeScript Playground) and using conditional types (TypeScript Playground), but none resolved the underlying issue.
The issue seems to stem from how TypeScript handles parameter inference in the context of default values, leading to unexpected any types.

If possible, I would appreciate any guidance on how to achieve the desired inference without resorting to explicit typing for each parameter. Additionally, any insights into whether this behavior is expected or if it may be addressed in future versions would be greatly appreciated.

@jcalz
Copy link
Contributor

jcalz commented Oct 14, 2024

Looks like a duplicate of #59643

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Oct 14, 2024

There's an interaction here with the contextual type that's a bit subtle. Consider this sequence of calls:

declare function foo<T extends string | number>(callback: (arg: T) => void): (arg: T) => T;
const p1 = foo(n => n)("hello"); // OK
const p2 = foo((n?) => {
    return n
})("hello"); // OK;
const p3 = foo((n?) => {
    n = n ?? 2;
    return n
})("hello"); // OK;
const p4 = foo((n = 2) => {
    return n
})("hello"); // OK;

If p1 is ok, then p2 should be OK, and if p2 is OK then p3 should be OK, and if p3 is OK then p4 should also be OK. But you're basically saying p4 should not be OK, which implies that one of these equivalences is wrong, or that p1 should be rejected (how?)

For the most part, TS views parameter defaults as an implementation detail of the function, not an intrinsic part of the signature.

@RyanCavanaugh RyanCavanaugh added the Not a Defect This behavior is one of several equally-correct options label Oct 14, 2024
@jcalz
Copy link
Contributor

jcalz commented Oct 14, 2024

( Presumably you either want declare function foo<T extends string | number>(callback: (arg?: T) => void): (arg: T) => T; or declare function foo<T extends string | number | undefined>(callback: (arg: T) => void): (arg: T) => T; in that example )

Sure, but given that function bar(n = 2) { return n } infers n as being of type number, and that const p5 = foo(bar)("hello") is the error p4 would give, there's some kind of interplay between default arguments and the function signature, right? To me this looks like #59643, which is interpreted as a feature request of the form "wouldn't it be nice if default arguments could, I don't know, maybe be consulted when inferring a parameter in the presence of a particularly unhelpful contextual type?"

@RyanCavanaugh
Copy link
Member

It's also close to #58977 which basically proposes the same interaction at call sites

@supitsdu
Copy link
Author

Thanks, @jcalz and @RyanCavanaugh! I've made a workaround that works perfectly for my needs:

type AnyActions = Record<string, any>;

// Utility type to infer parameters for functions supporting default parameters and the `this` keyword
type InferParametersWithContext<F extends Record<any, any>, T extends any = undefined> = {
    // Iterate over each key in the action mapping
    [Key in keyof F]: 
        // Check if the function matches the expected structure
        F[Key] extends (...params: infer A) => infer R ?
        // If so, infer the parameters and return type while specifying `this` context
        (this: T, ...params: A) => R : 
        // Otherwise, fallback to using the original function's parameters and return type
        (this: T, ...params: Parameters<F[Key]>) => ReturnType<F[Key]>
};

type Options<F extends AnyActions> = {
    actions?: InferParametersWithContext<F, {source: number}>;
};

TypeScript Playground Example

Thanks again! I believe the issue can be closed now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Not a Defect This behavior is one of several equally-correct options
Projects
None yet
Development

No branches or pull requests

3 participants