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

Boolean function inference has incorrect behavior #51184

Closed
scottmas opened this issue Oct 14, 2022 · 19 comments
Closed

Boolean function inference has incorrect behavior #51184

scottmas opened this issue Oct 14, 2022 · 19 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@scottmas
Copy link

scottmas commented Oct 14, 2022

Bug Report

Typescript is unable to correctly determine that a function's return satisfies the specified type.

🔎 Search Terms

"Typescript expanding boolean into true/false" (#30029)

🕗 Version & Regression Information

v. 4.7.4

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

⏯ Playground Link

https://www.typescriptlang.org/play?ts=4.9.0-dev.20221014#code/LAKALgngDgpgBAIQIYBMBiA7OBeOAKPAShwD44wAnAVxmIB98jS4AzJAGwGdaBuUAYwD2GTmHIxRALkSpMOfMWxkA3qDhwKMMFQpYAskjAALAHQUkGFIIC2TMgAY+IAL48gA

💻 Code

type SomeInferredFunctionType = (() => true) | (() => false); 

// Gives TS error!
const test: SomeInferredFunctionType = () => { 
  return Math.random() > 0.5;
};

🙁 Actual behavior

Typescript thinks test does not satisfy its given type when it does. Note, that it is possible (with much difficulty) to collapse SomeInferredFunctionType into the correct simplified type() => boolean when I have direct access to the type. However, in my codebase, the type is actually automatically inferred and I do not have access to it and I am just consuming it.

🙂 Expected behavior

Typescript should be able to determine that test satisfies the condition given by SomeInferredFunctionType

@scottmas scottmas changed the title Boolean inference has incorrect behavior Boolean function inference has incorrect behavior Oct 14, 2022
@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Oct 14, 2022
@RyanCavanaugh
Copy link
Member

This is correct behavior. SomeInferredFunctionType describes a value that is either a) a function that always returns true or b) a function that always returns false. A function that returns Math.random() > 0.5 is neither of those.

@scottmas
Copy link
Author

scottmas commented Oct 14, 2022

Yes, () => true is different from () => boolean. However, in this particular case the types should collapse. In every logical system I can conceive of, (() => true) | (() => false) is equivalent to () => boolean. With access to the types, the type can also be collapsed correctly as follows:

type SomeInferredFunctionType = (() => true) | (() => false);

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
type ExtractFnInfo<T> = T extends (arg: infer A) => infer V ? { arg: A; val: V } : never;
type ExtractArg<U> = U extends { arg: infer A } ? A : never;
type ExtractRet<U> = U extends { val: infer A } ? A : never;

type SimplifyFn<T extends Function> = (
  arg: Simplify<UnionToIntersection<ExtractArg<ExtractFnInfo<T>>>>,
) => ExtractRet<ExtractFnInfo<T>>;

//Happy :)
const test: SimplifyFn<SomeInferredFunctionType> = () => {
  return Math.random() > 0;
};

However, the above is not an option for me since I do not have direct access to SomeInferredFunctionType

@fatcerberus
Copy link

fatcerberus commented Oct 14, 2022

(() => true) | (() => false) is equivalent to () => boolean

That's only true if you assume the functions in question are mathematically pure (i.e. that the effects of calling the function are not observable beyond the return value, and depend only on the value(s) received as input). Incidentally Math.random is not, and functional purity is not, in general, a safe assumption for a type system built on top of JS to make.

@RyanCavanaugh
Copy link
Member

... However, in this particular case the types should collapse.

Why? Naively, the provided function does not satisfy any constituent of the union. I don't know what logic you're using here to make this reduction step -- it's plainly wrong in the presence of nonpure functions, as noted by fatcerberus just now.

@scottmas
Copy link
Author

Can you give me a concrete example of real life functions of type (() => true) | (() => false) would NOT be equivalent to () => boolean?

As a general rule, I'm not proposing that we collapse types like that, but I'm just saying I can't think of any specific use case where this specific function types shouldn't be equivalent.

@RyanCavanaugh
Copy link
Member

If I say I accept f of type () => true | () => false, then the tuple [f(), f()] must be of type [true, true] | [false, false] (TS does not do this combinatorial expansion, but in principle this is what that union type means). The provided function is capable of returning [true, false] which is outside the domain of [true, true] | [false, false]

@fatcerberus
Copy link

I could 100% envision code that depends on that invariant too.

@scottmas
Copy link
Author

scottmas commented Oct 14, 2022

Yes, that's clearly what the Typescript compiler is doing internally. But you're using the self-contained logic of set notation here. And maybe I'm wrong, but to me it seems that smart outside observer logic should be prioritized over set logic. And I would argue that a smart outside observer (who doesn't have the equivalent of a phd in set theory like TS core devs) would think those two types should be equivalent.

Also concrete example would be great! Maybe I just need to see a real life example for what you're saying to make more sense to me.

@RyanCavanaugh
Copy link
Member

Do you think that for all T and U, () => T | () => U and () => T | U are equivalent? Or is this logic particular to boolean?

@fatcerberus
Copy link

fatcerberus commented Oct 14, 2022

"If you call the function twice you get the same type" seems like an invariant that real code could easily depend on (there's almost certainly already code that does), no deferral to set theory necessary.

@scottmas
Copy link
Author

scottmas commented Oct 14, 2022

No, those definitely should not be equivalent. It's just in this boolean case. You can see it in the comment mentioned issue but the problem primarily arises from the fact that boolean is considered a ts primitive by most devs. #30029

And that's a good example @fatcerberus I hadn't considered. With the conditional type, in theory you should be able to do type narrowing (although currently the ts compiler isn't smart enough to do so).

Maybe the real issue is the convoluted inference process that is generating this conditional type and I should isolate that issue and write up a bug report.

@RyanCavanaugh
Copy link
Member

Why is boolean different?

@fatcerberus
Copy link

fatcerberus commented Oct 14, 2022

@RyanCavanaugh I assume the relevant distinction is unit type vs. not. And boolean is (somewhat counterintuitively) a union of unit types, so distributive types tend to break it apart

@RyanCavanaugh
Copy link
Member

It's honestly a little unfortunate that we made boolean secretly a union; it makes sense but doesn't match intuition with the other primitives which have infinite domains. Likely this is going to boil down to a conditional type with an incorrectly distributive type parameter.

@scottmas
Copy link
Author

I'm doing some complex type inference stuff and deep down in the chain of logic, this is the core offending logic:

type Example<T> = T extends any ? {val: T} : never
type Bool = Example<T> // Equals { val: false } | { val: true }

I then use the inferred types to generate an inferred function type.

@scottmas
Copy link
Author

Well, I think I'll just close this. There's no good way to solve this issue without likely causing more problems than it solves. Maybe one day on a major TS bump it might make sense to explore the ramifications of making boolean not secretly be true | false. But for now I can fix the problem with a little bit of repetition. AKA

type DoSomething<T> = T extends true
  ? DoAnotherThing<boolean>
  : T extends false
  ? DoAnotherThing<boolean>
  : DoAnotherThing<T>;

@fatcerberus
Copy link

@scottmas You know you can just disable the distributive behavior, right?

type Foo<T> = [T] extends [boolean] ? T[] : unknown[];
type Test = Foo<boolean>;  // boolean[] - NOT true[] | false[]

@scottmas
Copy link
Author

Ahh yes, thank you

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

3 participants