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

Function type parenthesized when used in a union type prevents usage of conditional function returns #56705

Closed
citkane opened this issue Dec 7, 2023 · 13 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@citkane
Copy link

citkane commented Dec 7, 2023

🔎 Search Terms

"Function type notation must be parenthesized when used in a union type"

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about "Function type notation must be parenthesized"

⏯ Playground Link

https://www.typescriptlang.org/play?#code/GYVwdgxgLglg9mABMOAnA1gCgM5wLYCmAagIYA2IBA-AFyLZSoxgDmAlAN4BQiviqBKCFRIAhKNyFSFAoir18xcpUR0UGLgF8uXCAlxkCAOjJwWmdVgDkkpTKts2AbkQB6V4igALGNkQB3NHRsLndEAAEobABaGBYwNAJdfThDEzMLIMw2TBtFAHlvAlRpSgdnNw9vXwCg7AAaRAAjEChPL1Q4fz8STz89PAAHGENURGLO1B1XACoZnhmArwIkAElPVABPTzhEPQJiiFkivbQBaE9NwYJGmAInBcQrZ8ejN9V6RmYWRAAfREwADdyLRPkxWGwALwAPkQJDAm0ezysj0e6xYgnasgGw1G41QkzoAAMAGLgaDwJBQK6yBJQEiwBCIPAgBjNWSDEgCMBFbAwABeBAAJksVohWcLEMw4eKwJTLtcjJgAIwAZgAHABWNhEhauIA

💻 Code

function fork(someValue?: string){
    return !!someValue ? someValue : fork
}

console.log(fork('someValue')); // this works
// @ts-ignore
console.log(fork()('someOtherValue')); // this works, but throws a ts compiler error

/**
 * when I try to coeerce the correct type, ie;
 * '''
 * ... : string | (val?: string)=> any
 * '''
 * 
 * I get the compiler error: `Function type notation must be parenthesized when used in a union type.(1385)`
 */

🙁 Actual behavior

The compiler prevented a programming pattern available in native ES.
Coercing the type to the correct form led to another TS error.

🙂 Expected behavior

I expected the compiler to allow all ES patterns without needing to use //@ts-ignore

Additional information about the issue

No response

@MartinJohns
Copy link
Contributor

MartinJohns commented Dec 7, 2023

This is working as intended.

You get an error, because your function can return either a string or a callable function, but you always try to call it on line 7. What if it's a string, not a function? You'd get a runtime error, from which the compiler protects you.

The type string | (val?: string)=> any just needs to be written as string | ((val?: string)=> any) to prevent any ambiguity.

If you meant to write a function that returns a string when an argument is provided and a callable function when no argument is provided, then you should use Function Overloads.

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Dec 7, 2023
@citkane
Copy link
Author

citkane commented Dec 7, 2023

In my above given example, returning either a string or a callable function is exactly what it intends to do. I will illustrate the intent with a more relatable (simplified) use example;

Given the data structure:

type data = Record<string, {value: any, children: data}>

I want to avoid retrieving things like so:

data['level1'].children['level2'].children['level3'].value

and instead do:

getData('level1')('level2')('level3')()

I may also want to make some more decisions along the data retrieval path.

This can be implemented in native JS;

const inputData = {
  level1: {
    value: 'level1',
    children: {
      level2: {
        value: 'level2',
        children: {
          level3: {
            value: 'level3',
          },
        },
      },
    },
  },
};

function getData(key) {
  return ((data) => fork.bind(null, data))(inputData[key]);
  function fork(data, key) {
    return ((children, val) => {
      /** Do some interesting stuff here */
      return !!key ? fork.bind(null, children[key]) : val;
    })(data.children, data.value);
  }
}

console.log(
  getData('level1')(),
  getData('level1')('level2')(),
  getData('level1')('level2')('level3')(),
);
// level1 level2 level3

The above is valid working code which does not need protection from runtime errors. There is enough space for input validation and error handling within the code.

As can be seen in this playground, which recreates the above both with and without overloading, Typescript prevents it from running without error suppression using //@ts-ignore. Once suppressed, the code compiles and runs correctly

It is apparent that the compiler (in this scenario) is insisting on wrapping a return function type in parenthesis, which means that the type is inconsistent with what the compiler is actually doing.

This is both opinionated, and imo, a bug.

@fatcerberus
Copy link

fatcerberus commented Dec 7, 2023

It is apparent that the compiler (in this scenario) is insisting on wrapping a return function type in parenthesis, which means that the type is inconsistent with what the compiler is actually doing.

I have no clue what you're trying to say with this, but I'm 99% sure it's a red herring. The two problems you have are:

  1. TS wants you write string | ((foo: Bar) => Baz), which doesn't change the intended meaning of the type, it just avoids parsing ambiguities.
  2. Since you're now trying to call something of type string | FunctionType, the compiler doesn't know which one it is and doesn't let you write the call without narrowing away the string possibility first. You might need something like Relate control flow to conditional types in return types #33912 to fully resolve this.

@citkane
Copy link
Author

citkane commented Dec 7, 2023

I have no clue what you're trying to say with this, but I'm 99% sure it's a red herring

I will clarify:

The compiler wants me to change the typing to string | ((foo: Bar) => Baz) for it's internal reasons, but this is not actually how the code interface gets consumed, so I can't call my function because ((foo: Bar) => Baz) is not a function (as the type checker now understands it).

You can see this on lines [32-34] and [48-50] of the playground example.

As I illustrated in the JS example given above, this is valid running code and I would like to use Typescript to write this code without resorting to //@ts-ignore

@fatcerberus
Copy link

You can’t call the function because it’s typed as string | function; the parentheses have nothing to do with it.

@RyanCavanaugh
Copy link
Member

The compiler wants me to change the typing

It's asking you to clarify a syntactic ambiguity that other humans will have a problem with, not "change the typing"

this is valid running code

I really can't tell that it is. Either this function can return a string or a function or it always returns a function. If the former, then it's not safe to unconditionally invoke its result. If the latter, then your annotated return type is wrong. TS doesn't have a mechanism to say "This function will return whatever type you hope it will"; this isn't how code works.

@citkane
Copy link
Author

citkane commented Dec 7, 2023

The native code example I gave (without typing) works perfectly well. If you are saying that this is not achievable in TS without //@ts-ignore I will accept it and we can close this discourse. If not, I would appreciate an example of how to implement the example with typings.

Link to the native code running: https://onecompiler.com/javascript/3zvr43fha

An here is a TS Playground of the TS code compiling and running (albeit with //@ts-ignore)

@nmain
Copy link

nmain commented Dec 7, 2023

You can certainly write meaningful types for these sorts of things. I'm not working through your example because this is not a support forum, but something like this should get you headed in the right direction:

interface TreeBase {
    value?: string;
    children: Record<string, TreeBase>;
}

interface Getter<T extends TreeBase>{
    (): T["value"];
    <K extends keyof T["children"] & string>(node: K): Getter<T["children"][K]>;
}

@citkane
Copy link
Author

citkane commented Dec 7, 2023

@nmain , That results in Typescript that compiles and runs. Thank you.

I will argue that it is a circuitous workaround (however clever it is) as opposed to just being able to declare the type for what it is up front, ie: string | (key?:string) => typeof fork.

The need to wrap the returned function in parenthesis, ie: string | ((key?:string) => typeof fork) is purely an internal TS matter which is exposed to the developer as a puzzling dilemma of consuming function calls erroring, but compiling and running when ignored.

It is my opinion that end user experience issues, including that of consuming developers, can be bugs, and that this one is a bug.

@MartinJohns
Copy link
Contributor

MartinJohns commented Dec 7, 2023

The need to wrap the returned function in parenthesis, ie: string | ((key?:string) => typeof fork) is purely an internal TS matter which is exposed to the developer as a puzzling dilemma of consuming function calls erroring, but compiling and running when ignored.

It seems you're still confusing two entirely different issues. One is the parsing ambiguity, which the parenthesis solves. It does not change the rest of the code or behavior in any way.

The actual issue with the type string | ((key?:string) => typeof fork) is that it's either a string, or a callable function, and the compiler does not know which of these two it is, so it's not allowed to call this type. This is the intended behavior and not a bug, it's just a lack of understanding of TypeScripts type system.

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Dec 7, 2023

The need to wrap the returned function in parenthesis, ie: string | ((key?:string) => typeof fork) is purely an internal TS matter which is exposed to the developer as a puzzling dilemma of consuming function calls erroring

This was done on purpose because the type () => string | number is confusing to humans (is it a function returning a string or number, or is it a number or function returning a string?). The rule is maximally consistent -- function types in unions must be parenthesized.

Projects making decisions differently is not "bug".

@citkane
Copy link
Author

citkane commented Dec 7, 2023

One is the parsing ambiguity, which the parenthesis solves. It does not change the rest of the code or behavior in any way.

It changes the code behavior, as the code becomes un-callable. You can see this on lines [32-34] and [48-50] of this playground example.

This was done on purpose because the type () => string | number* (sic function) is confusing to humans

Fair enough. I did not expected such an a opinion to be embedded into a compiler.

I know enough of type systems in a few languages to get by, but I am not at all an expert.

The point I am making is that the code runs, it can compile without type errors (thanks again @nmain), but the implementation is circuitous and counter intuitive (at least for this human). Do with that what you feel. I feel it is a bug, but also do as you feel if you want to close this issue.

@fatcerberus
Copy link

fatcerberus commented Dec 7, 2023

It does not change the behavior - a type of the form string | (() => number) is simply not callable as-is, and this would be true even if the compiler allowed you to remove the parentheses on the function type. The fact that you can make the error go away by adding a @ts-ignore is immaterial.

@RyanCavanaugh RyanCavanaugh closed this as not planned Won't fix, can't repro, duplicate, stale Dec 7, 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