Repository navigation
Intersection of conditional types, acting on a generic type, with another type doesn't narrow the generic typeΒ #57246
Description
π Search Terms
- intersect
- conditional
- generic
- narrow
- typeof
- function
- callable
- ts2349
π Version & Regression Information
This is the behavior in every version I tried, and I reviewed the FAQ for entries about narrowing.
β― Playground Link
π» Code
type Thunk<A> = () => A;
type NonFunction<A> = A extends Function ? never : A;
type Expr<A> = Thunk<A> | NonFunction<A>;
const evaluate = <A>(expr: Expr<A>): A =>
typeof expr === "function" ? expr() : expr;
// ^^^^
// -------------------------------------------------------------------------------------
// This expression is not callable.
// Not all constituents of type 'Thunk<A> | (NonFunction<A> & Function)' are callable.
// Type 'NonFunction<A> & Function' has no call signatures. (2349)
// -------------------------------------------------------------------------------------
// (parameter) expr: Thunk<A> | (NonFunction<A> & Function)
// -------------------------------------------------------------------------------------π Actual behavior
The parameter expr, which has the type Expr<A>, is being narrowed by the typeof expr === "function" condition. When the condition is true, the type of expr is narrowed to Thunk<A> | (NonFunction<A> & Function). When the condition is false, the type of expr is narrowed to NonFunction<A>.
π Expected behavior
When the condition is true, the type of expr should be simplified to just Thunk<A>.
Thunk<A> | (NonFunction<A> & Function)
= Thunk<A> | ((A extends Function ? never : A) & Function) // (1) by definition
= Thunk<A> | (A extends Function ? never & Function : A & Function) // (2) distributivity
= Thunk<A> | (A extends Function ? never : A & Function) // (3) annihilation
= Thunk<A> | (A extends Function ? never : never) // (4) law of non-contradiction
= Thunk<A> | never // (5) simplification
= Thunk<A> // (6) identityThe challenging step to understand is step number 4 where we invoke the law of non-contradiction to simplify the type A & Function to never. The law of non-contradiction states that some proposition P and its negation can't both be true. In our case, the proposition is Function. Since A & Function is in the else branch of the conditional A extends Function, it implies that A is not a function. Hence, A and Function are contradictory. Thus, by the law of non-contradiction we should be able to simplify it to never.
In plain English, NonFunction<A> & Function is a contradiction. A type can't both be a Function and a NonFunction<A> at the same time. Hence, the type checker should simplify NonFunction<A> & Function to never.
At the very least, NonFunction<A> & Function should be callable. We shouldn't get a ts2349 error.
Additional information about the issue
I understand that as TypeScript is currently implemented, NonFunction<A> & Function doesn't simplify to never for all possible types A. For example, consider the scenario when A is unknown.
NonFunction<unknown> & Function
= (unknown extends Function ? never : unknown) & Function // (1) by definition
= unknown & Function // (2) simplification
= Function // (3) identityThis seems to be because in step number 2 we're simplifying unknown extends Function ? never : unknown to just unknown. Instead, we should simplify it to unknown & !Function where the ! denotes negation, i.e. an unknown value which is not a function. Then we can apply the law of non-contradiction to get the correct type.
NonFunction<unknown> & Function
= (unknown extends Function ? never : unknown) & Function // (1) by definition
= (unknown & !Function) & Function // (2) simplification
= unknown & (!Function & Function) // (3) associativity
= unknown & never // (4) law of non-contradiction
= never // (5) annihilationAt the very least, this seems to suggest the need for some sort of negation type.
Activity
MartinJohns commentedon Feb 1, 2024
#4196
fatcerberus commentedon Feb 1, 2024
It's actually not true that
NonFunction<A> & Functionis a contradiction in TypeScript. Because object types are not sealed1, intersecting objects with disjoint sets of properties merges them, and function types have a special pseudo-property called a "call signature". So:Footnotes
Which, for those keeping score, also implies that a
NonFunction<A>may be a function in reality, and you've just forgotten its call signature. The call signature can be reintroduced via type intersection. β©aaditmshah commentedon Feb 1, 2024
Bruce Pascoe (@fatcerberus) Can you give me a real life example of how you would construct a value of type
MoreFunin JavaScript?fatcerberus commentedon Feb 1, 2024
All functions are objects in JavaScript.
aaditmshah commentedon Feb 1, 2024
Bruce Pascoe (@fatcerberus) I understand that. In fact, I wrote about this very issue in the Additional information about the issue section.
However, that's what I think is wrong. I think that in your example
MoreFunshould simplify tonever. In fact, I think thatNonFunction<T> & F, for all typesTand for all typesFwhich extendFunction, should simplify tonever.Note that I have no issue with the type
Obj & Fun. I only have an issue with the typeNonFunction<Obj> & Fun. I think it's a mistake forNonFunction<Obj>to simplify toObj. I think it should simplify toObj & !Function. Hence,NonFunction<Obj> & Funshould simplify toneveras follows.RyanCavanaugh commentedon Feb 1, 2024
It's not practical for every possible non-inhabitable type to be simplified to
never-- this requires a potentially unbounded amount of work. Because it can't be done, it's not an invariant you should be trying to take a dependency on.fatcerberus commentedon Feb 1, 2024
But that's the thing - those are the same type. Type aliases are just type-functions -
NonFunction<Obj>"returns"Obj, so in the end you are just doingObj & Fun. TS doesn't go back and reevaluate the conditional type every time you manipulate it - that's not how they work.fatcerberus commentedon Feb 1, 2024
If we had negated types, you could just write the latter directly, but I disagree that that's what the conditional type you wrote should simplify to. Ternary expressions aren't retroactive at runtime, so I wouldn't expect the typespace equivalent to be either.
aaditmshah commentedon Feb 2, 2024
Yes,
NonFunction<Obj>"returns"Objcurrently but that's a defect of the type system, which is why I created this issue.The type
NonFunction<Obj>should returnObj & !Function. You don't need to go back and re-evaluate the conditional type. Just make the conditional type returnObj & !Function.By simplifying
NonFunction<Obj>to justObj, we're losing type information. In particular, we're losing the information that we checked whetherObjis a function and we found out that it isn't a function. The check is the important part here.True, if we had negated types then I could define
NonFunctionas follows, which by the way is oh so perfect.However, I disagree with the second part of your statement. A conditional type of the form
A extends B ? never : Ashould simplify toA & !Bfor all typesAandB. And, here's the proof.First, we need to understand what$A \to B$ .
A extends Bdenotes. Turns out,extendsdenotes material implication. IfAextendsBthen we should be able to substitute a value of typeAeverywhere a value of typeBis expected. That is,Ashould be "assignable" toB. In propositional logic, we would write this asNext, the$\bot$ in propositional logic. Finally, any conditional type $(P \to Q) \land (\neg P \to R)$ in propositional logic.
nevertype denotes falsehood and is represented asP ? Q : Rcan be represented as the conjunction of two conditionalsPutting it all together, the conditional type$((A \to B) \to \bot) \land (\neg (A \to B) \to A)$ . However, $P \to \bot$ is the same as $\neg P$ . Hence, the proposition can be simplified to $\neg (A \to B) \land (\neg (A \to B) \to A)$ .
A extends B ? never : Awould be represented in propositional logic asAt this point, we can use the modus ponens rule to infer that$A$ is a true proposition. However, inference is not the same as simplification. Yes, $(\neg (A \to B) \land (\neg (A \to B) \to A)) \to A$ is a true proposition. However, $\neg (A \to B) \land (\neg (A \to B) \to A) \neq A$ . Since, $P \land (P \to Q)$ simplifies to $P \land Q$ , we can simplify $\neg (A \to B) \land (\neg (A \to B) \to A)$ to $\neg (A \to B) \land A$ .
Finally,$\neg (A \to B) \land A$ can be simplified to $\neg (\neg A \lor B) \land A$ because $P \to Q$ is equivalent to $\neg P \lor Q$ . Next, by De Morgan's law we can further simplify $\neg (\neg A \lor B) \land A$ to $(A \land \neg B) \land A$ . And, now we can trivially see that $(A \land \neg B) \land A$ is just $A \land \neg B$ .
Now, you might be think that there's some flaw in my argument because I'm treating types as propositions. After all, types are not the same as propositions. But, the CurryβHoward isomorhpism is proof that types and propositions are indeed equivalent. It's one of the seminal theorems of computer science.
In conclusion,
A extends B ? never : Ashould simplify toA & !B. Inference is not the same as simplification. Yes,A extends B ? never : AimpliesA. However, it also implies!B.aaditmshah commentedon Feb 2, 2024
I understand that the solution this for this problem might take a lot of work to implement, and therefore it's not something that you want to solve. However, this is indeed a defect of the type system as I demonstrated using propositional logic above. Hence, I don't think the "Not a Defect" label is justified here.
To be fair, I can easily solve this problem using type guards.
However, I expected TypeScript to correctly narrow the type of
exprtoThunk<A>even without type guards. It's quite obvious that an inhabitant ofExpr<A>can only be a function if it's an inhabitant ofThunk<A>.fatcerberus commentedon Feb 2, 2024
That would make the conditional type retroactive, which, again, is not how they work. That would be like writing a function:
and then expecting it to throw if, at any point,
xis transformed into a string, such as by callingString(x). In other wordsNonFunction<Obj> & Funis completely analogous to callingString(nonString(x))at runtime.A conditional type is a computation done on a type at compile time, not a new kind of "dynamic type".
RyanCavanaugh commentedon Feb 2, 2024
The problem literally isn't computable. It's not for lack of desire to compute BB(12) that I haven't.
aaditmshah commentedon Feb 3, 2024
I don't understand what you mean by "retroactive". Could you please provide a definition for this term? This is the second time you've used this term and I'm honestly confused as to what you're trying to convey.
Also, could you please elucidate the following statement?
Again, I don't understand what you mean by "retroactive" and hence I don't understand what "typespace equivalent" means in this sentence either.
Your analogy is wrong. If you want to view types as values then you should reify them as predicates. The predicate takes any value, and returns
trueif and only if that value satisfies the type that the predicate reifies.As you can see, the
absurdtype always returnsfalseno matter what its input is. This is because a value can't both be a non-function and a function at the same time.Also note that the
intersecttype constructor requires that the same value satisfy both the types provided. I mention this to refute your statement aboutxsomehow transforming intoString(x).xdoesn't transform intoString(x). They are two separate values. Hence, your analogy fails there.Anyway, you might have noticed that I defined
NonFunction<A>asA & !Functioninstead ofA extends Function ? never : A. This was on purpose because I want to pose two questions two you.NonFunction<A>was defined asA & !Function, would you agree then thatNonFunction<A> & Functionshould simplify tonever? If not, why?A extends Function ? never : AtoA & !Function, would you agree that they are indeed equivalent? By mechanical, I mean that I'll provide a computer algorithm which will reduceA extends Function ? never : AtoA & !Functionin a finite number of steps regardless of the typeA. This means that we'll literally be able to implement said algorithm in the TypeScript type system.So is the algorithm I have in mind to simplify
A extends Function ? never : AtoA & !Function.aaditmshah commentedon Feb 3, 2024
Interesting, so you're telling me that this particular problem is undecidable? I would love to understand why. Would you explain it to me?
What is "BB(12)"?
11 remaining items
fatcerberus commentedon Feb 5, 2024
More concretely:
You asked whether
Animalwas assignable toDog. It's true that it isn't. If you then narrowTtoT & !Dogin the false branch, you're asserting thatDogis not assignable to the originalT, which is false.aaditmshah commentedon Feb 5, 2024
Bruce Pascoe (@fatcerberus) Let me formalize what you're saying so that I understand it.
Functionas the propositionT = A & !Functioncan be represented as the propositionF extends Functioncan be represented as the propositionFbecauseFunction.F extends Tcan be represented asAis not a function andGis a function thenGcan't be assigned toA.That makes sense to me. I don't see any problem here.$\forall A. \neg (A \to F) \to \forall G. (G \to F) \to \neg (G \to \neg (A \to F))$ is indeed true.
I certainly don't see how the proposed narrowing is wrong.
Why would you even care if$T \to U$ . The negation of $T \to U$ is not $U \to T$ . The negation of $T \to U$ is material nonimplication, i.e. $\neg (T \to U)$ .
Uis assignable toT? The propositionT extends Uis just material implication. It's equivalent toSo, it makes perfect sense to set
T = T & !Uin the false branch.You don't make any sense. If
TisAnimal, thenT & !DogisAnimal & !Dog. It represents the values which are assignable toAnimalbut not assignable toDog, which makes perfect sense. Nowhere are we asserting thatDogis not assignable toAnimal.Animal & !Dogis not the same as!(Dog extends Animal)because!(Dog extends Animal)simplifies toDog & !Animal.fatcerberus commentedon Feb 5, 2024
In fact you are.
Animal & !Dogmeans, as you say, "value which is assignable toAnimalbut not assignable toDog", i.e. it's a set which contains no dogs. It is not valid to conclude that, just becauseTis not a subset ofDog, thatTcontains no dogs. The conditional type expression operates on the entire type, not individual members of it, or even individual subtypes (modulo distribution over union types)aaditmshah commentedon Feb 5, 2024
I literally don't see the check for
Dog extends Tanywhere in your example. And,Animal & !Dogis literally assignable toAnimal.aaditmshah commentedon Feb 5, 2024
We're not concluding that
Animalcontains no dogs. We're concluding thatAnimal & !Dogcontains no dogs. There's a very big difference.fatcerberus commentedon Feb 5, 2024
You're arguing that
!(T extends Dog)always impliesT & !Dog, which is not true, becauseTmight be (and is, in my example)Animal.Dog | Cat | Horseis not a subset ofDog, but it still contains dogs.Cat | Horsecontains no dogs, so you've changed the meaning of the original type by narrowing it.aaditmshah commentedon Feb 5, 2024
Yes,
!(T extends Dog)always impliesT & !Dog, even ifTisAnimal. It's impossible to assign aDogtoNonDog<Animal>.Playground Link
What does that have to do with anything?
I haven't. You literally can't assign a
DogtoNonDog<Animal>.somebody1234 commentedon Feb 5, 2024
Aadit M Shah (@aaditmshah) counterexample:
Open in Playground
aaditmshah commentedon Feb 5, 2024
somebody1234 Your counterexample has errors.
Playground link
aaditmshah commentedon Feb 5, 2024
Listen, as much as I love to argue with random strangers on the internet, I'm going to have to withdraw from this conversation now. It's pointless and unhealthy. Feel free to keep commenting, but I'm not going to reply. In fact, I'm unsubscribing from this thread. I already closed this issue because it's a duplicate of #4196. Have a great day folks.
somebody1234 commentedon Feb 5, 2024
Aadit M Shah (@aaditmshah) it doesn't have errors, you modified the code so that it produced an error
somebody1234 commentedon Feb 5, 2024
hmm... i guess the idea doesn't quite check out
my bad.
somebody1234 commentedon Feb 5, 2024
Aadit M Shah (@aaditmshah) Here is a proper incorrect case:
Open in Playground
fatcerberus commentedon Feb 5, 2024
For arbitrary
TandU, "Tis not a subset ofU" does not imply "Tis a set which contains no elements fromU".T & !Umeans the latter.