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

Interface function of arity of zero gets inferred as having two different method signatures #53508

Closed
steveluscher opened this issue Mar 26, 2023 · 9 comments
Labels
Not a Defect This behavior is one of several equally-correct options

Comments

@steveluscher
Copy link

Bug Report

πŸ”Ž Search Terms

overloads, infer, interfaces, arity

πŸ•— Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about overloads

⏯ Playground Link

Playground link with relevant code

πŸ’» Code

type Overloads<T> = T extends {
    (...args: infer A1): infer R1;
    (...args: infer A2): infer R2;
  } 
  ? [(...args: A1) => R1, (...args: A2) => R2]
  : T extends {
      (...args: infer A1): infer R1;
    } 
    ? [(...args: A1) => R1]
    : any;

interface A {
  hello(str: string): string
}
type HelloOverloadsOfA = Overloads<A['hello']>; // GOOD [(str: string) => string]

interface B {
  hello(): number
  hello(str: string): string
}
type HelloOverloadsOfB = Overloads<B['hello']>; // GOOD [() => number, (str: string) => string]

interface C {
  hello(): number
}
type HelloOverloadsOfC = Overloads<C['hello']>; // BAD [(...args: unknown[]) => unknown, () => number]

πŸ™ Actual behavior

When the interface in question contains a single implementation of a function, having arity zero, the Overloads utility type infers it as two different functions:

  • (...args: unknown[]) => unknown, and
  • () => number

πŸ™‚ Expected behavior

I expected Typescript to infer it as () => number alone. This would be consistent with how this utility type infers other methods having a single implementation, but arity > 0.

@steveluscher steveluscher changed the title Function with arity of zero gets inferred as having two different method signatures Interface function of arity of zero gets inferred as having two different method signatures Mar 26, 2023
@RyanCavanaugh RyanCavanaugh added the Not a Defect This behavior is one of several equally-correct options label Mar 28, 2023
@RyanCavanaugh
Copy link
Member

FWIW trying to do this (inferring from an overload set) in the first place is probably a very bad idea; I don't think anyone has ever done this before and it's really never been considered as a way someone might use infer.

Anyway this inference follows from the inference algorithm: It's correct for C["hello"] to match against the 2-overload variant, since it can accept some possible instantiation of both overloads. It's correct to assign all of the candidates to A1 and R1, leaving zero candidates to assign to A2 and R2 (or vice versa), and at that point the other parametrs have zero candidates so fall back to unknown, and since C['hello'] has no parameters, it correctly can accept an unknown[]. The only plausible tweak would be to say that for overloads, the candidate-splitting logic shouldn't apply, but then you'd just get [() => number, () => number] which isn't really desirable in this type either.

It's definitely undesirable but I don't see a way to change the rules here without breaking something else much more badly. If someone wants to put together a PR that shows otherwise, happy to review it, but I think the algorithm is broadly working as designed here.

@Andarist
Copy link
Contributor

FWIW trying to do this (inferring from an overload set) in the first place is probably a very bad idea; I don't think anyone has ever done this before and it's really never been considered as a way someone might use infer.

I've done it here. I learned about this being possible from this monster.

You know how it is... never say never 🀣

@steveluscher
Copy link
Author

steveluscher commented Mar 29, 2023

While we're showing-and-telling, here's my use case in case it inspires anything.

I parse out overloads like this, then using them like this.

@steveluscher
Copy link
Author

steveluscher commented Mar 29, 2023

…since C['hello'] has no parameters, it correctly can accept an unknown[].

Can you explain this to me, @RyanCavanaugh? Normally, if I take a function that has no parameters (arity zero) and I try to call it with any parameters at all, that's a type error.

function noArgs() {}

noArgs(); // OK

noArgs(123); // ERROR: Expected 0 arguments but got 1
noArgs.call(undefined, 123); // ERROR Expected 1 argument but got 2

noArgs(...([] as unknown[])); // ERROR A spread argument must either have a tuple type or be passed to a rest parameter.
noArgs.apply(undefined, [] as unknown[]); // Target allows only 0 element(s) but source may have more

In that light, why is (...args: unknown[]) => unknown a suitable overload of () => number?

Playground link.

@Andarist
Copy link
Contributor

I think that what @RyanCavanaugh meant here is that:

type Zero = () => void
type UnknownArgs = (...args: unknown[]) => void

type Test = Zero extends UnknownArgs ? 1 : 0 // 1

const fn: (arg: number) => void = () => {}

// this call is OK, the type expects a number, and this is satisfied
// it doesn't matter if the implementation uses this param or not
// it won't crash on the surplus of arguments
fn(100)

@steveluscher
Copy link
Author

steveluscher commented Mar 31, 2023

OK. I think I see what's going on here.

Fact 1; we all agree on arity being enforced at the call level

Nobody here disagrees that this should be a type error, because a call is being made that violates the arity of this function:

function noArgs() {}
noArgs(123); // ERROR: Expected 0 arguments but got 1

Fact 2; assignability does not concern itself with arity

Whether it's proper to call a function with a given number of arguments is one thing, but whether it should be assignable to a type is quite another. Just because this function only cares about one argument doesn't mean it shouldn't be assignable to a type that provides for more.

function reportError(errorName: string) {}
function logStuff(onError: (errorName: string, errorCode: number) => void) {
  onError('bad thing', 500);
}

logStuff(reportError); // OK. The callback will only make use of the `errorName`, but that's fine.

Conclusion

So given that everything is working, as designed, the upshot is that I'm just hosed unless #29732 gets implemented. Ultimately the reason that I've built this overload-inferring utility type is because I need to map over every overload to modify the return type of each. In short, what I want to accomplish is this:

interface ThingDoer {
  doThing(): void;
  doThing(num: number): string;
  doThing(str: string): bigint;
}

type AsycThingDoer = Asyncify<ThingDoer>;
// Produces:
// {
//   doThing(): Promise<void>;
//   doThing(num: number): Promise<string>;
//   doThing(str: string): Promise<bigint>;
// }

type Asyncify<T> = // Impossible with Typescript as it is designed, currently.

@Andarist
Copy link
Contributor

FWIW trying to do this (inferring from an overload set) in the first place is probably a very bad idea; I don't think anyone has ever done this before and it's really never been considered as a way someone might use infer.

I'm not sure if this is the first test time that a test case like this is being introduced to the codebase but at the very least this PR "codifies" this as something that is being considered and one that should have some reliable semantics behind it.

@github-actions
Copy link

github-actions bot commented Jun 8, 2023

This issue has been marked as 'Not a Defect' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

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