Repository navigation
Intersection of conditional types, acting on a generic type, with another type doesn't narrow the generic typeΒ #57246
Description
Activity
MartinJohns commented
on Feb 1, 2024 ContributorMore actionsAt the very least, this seems to suggest the need for some sort of negation type.
Reacted by Aadit M ShahReacted by Aadit M ShahIt'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:type NonFunction<A> = A extends Function ? never : A; type Fun = () => void; type Obj = { foo: string }; type MoreFun = NonFunction<Obj> & Fun; declare const foo: MoreFun; foo.foo; // ok foo(); // also ok
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. β©
-
Bruce Pascoe (@fatcerberus) Can you give me a real life example of how you would construct a value of type
MoreFunin JavaScript?function foo() { console.log("you fooey bard!") } foo.foo = "foo";
All functions are objects in JavaScript.
Bruce Pascoe (@fatcerberus) I understand that. In fact, I wrote about this very issue in the Additional information about the issue section.
I understand that as TypeScript is currently implemented,
NonFunction<A> & Functiondoesn't simplify toneverfor all possible typesA.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.NonFunction<Obj> & Fun = (Obj & !Function) & Fun // by definition = Obj & (!Function & Fun) // associativity = Obj & never // law of non-contradiction = never // annihilation
RyanCavanaugh commented
on Feb 1, 2024 MemberMore actionsIt'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.- addedNot a DefectThis behavior is one of several equally-correct optionsThis behavior is one of several equally-correct options
on Feb 1, 2024 Note that I have no issue with the type
Obj & Fun. I only have an issue with the typeNonFunction<Obj> & FunBut 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.Reacted by Aadit M ShahI think [
NonFunction<Obj>] should simplify toObj & !Function.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.
Note that I have no issue with the type
Obj & Fun. I only have an issue with the typeNonFunction<Obj> & FunBut 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.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.I think [
NonFunction<Obj>] should simplify toObj & !Function.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.
True, if we had negated types then I could define
NonFunctionas follows, which by the way is oh so perfect.type NonFunction<A> = A & !Function;
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 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 as$A \to B$ .Next, the
nevertype denotes falsehood and is represented as$\bot$ in propositional logic. Finally, any conditional typeP ? Q : Rcan be represented as the conjunction of two conditionals$(P \to Q) \land (\neg P \to R)$ in propositional logic.Putting it all together, the conditional type
A extends B ? never : Awould be represented in propositional logic as$((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)$ .At 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.Reacted by Martin JohnsIt'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.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.
type Thunk<A> = () => A; type NonFunction<A> = A extends Function ? never : A; type Expr<A> = Thunk<A> | NonFunction<A>; const isThunk = <A>(expr: Expr<A>): expr is Thunk<A> => typeof expr === "function"; const evaluate = <A>(expr: Expr<A>): A => isThunk(expr) ? expr() : expr;
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>.Reacted by Ryan CavanaughThe 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.That would make the conditional type retroactive, which, again, is not how they work. That would be like writing a function:
function nonString(x) { // let's assume throw expressions are available for brevity return typeof x !== 'string' ? x : throw Error("no strings allowed!"); }
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 commented
on Feb 2, 2024 MemberMore actionsI 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
The problem literally isn't computable. It's not for lack of desire to compute BB(12) that I haven't.
That would make the conditional type retroactive, which, again, is not how they work.
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?
Ternary expressions aren't retroactive at runtime, so I wouldn't expect the typespace equivalent to be either.
Again, I don't understand what you mean by "retroactive" and hence I don't understand what "typespace equivalent" means in this sentence either.
That would be like writing a function:
function nonString(x) { // let's assume throw expressions are available for brevity return typeof x !== 'string' ? x : throw Error("no strings allowed!"); }
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.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.// Analogous to the type `unknown`. const unknown = () => true; // Analogous to the type `Function`. const func = (x) => typeof x === "function"; // Analogous to the type `NonFunction<A> = A & !Function`. const nonFunc = (t) => (x) => t(x) && !func(x); // Analogous to the type `A & B`. const intersect = (t1, t2) => (x) => t1(x) && t2(x); // Analogous to the type `NonFunction<A> & Function`. const nonFuncAndFunc = (t) => intersect(nonFunc(t), func); // Analogous to the type `never`. const absurd = nonFuncAndFunc(unknown); console.log(absurd(42)); // false console.log(absurd(() => 42)); // false
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.- If TypeScript supported negated types and
NonFunction<A>was defined asA & !Function, would you agree then thatNonFunction<A> & Functionshould simplify tonever? If not, why? - If I showed you a step-by-step mechanical way to simplify
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.
A conditional type is a computation done on a type at compile time, not a new kind of "dynamic type".
So is the algorithm I have in mind to simplify
A extends Function ? never : AtoA & !Function.- If TypeScript supported negated types and
The problem literally isn't computable.
Interesting, so you're telling me that this particular problem is undecidable? I would love to understand why. Would you explain it to me?
It's not for lack of desire to compute BB(12) that I haven't.
What is "BB(12)"?
11 remaining items
More concretely:
type NonDog<T> = T extends Dog ? never : T; type Test = NonDog<Animal>;
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.Reacted by Aadit M ShahI think I might see where you're coming from here; you envision the
Aas being narrowed in the false branch due to theextendscheck, making it not an identity in that context. Even so, what you propose is still incorrect:T = A & !Functionimplies thatF extends Tis false for all typesF extends Function, which is something that was not checked in the conditional type. So the proposed narrowing is still wrong, even if negated types existed.Bruce Pascoe (@fatcerberus) Let me formalize what you're saying so that I understand it.
- Let's represent the type
Functionas the proposition$F$ . -
T = A & !Functioncan be represented as the proposition$T = \neg (A \to F)$ . -
F extends Functioncan be represented as the proposition$G \to F$ . - I used
$G$ to representFbecause$F$ representsFunction. -
F extends Tcan be represented as$G \to T$ which is$G \to \neg (A \to F)$ . - So, what you're saying is that
$\forall A. \neg (A \to F)$ implies$\forall G. (G \to F) \to \neg (G \to \neg (A \to F))$ . - In plains words, if
Ais 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.
tl;dr is that, in general,
T extends Ubeing true or false only tells you whetherTis assignable toUbut says nothing about whetherUis assignable toT. So it doesn't make sense to setT = T & !Uin the false branch of said check.Why would you even care if
Uis assignable toT? The propositionT extends Uis just material implication. It's equivalent to$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)$ .-
$T \to U$ is equal to$\neg T \lor U$ . -
$\neg (T \to U)$ is equal to$\neg (\neg T \lor U)$ which is$T \land \neg U$ .
So, it makes perfect sense to set
T = T & !Uin the false branch.More concretely:
type NonDog<T> = T extends Dog ? never : T; type Test = NonDog<Animal>;
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.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.- Let's represent the type
Nowhere are we asserting that
Dogis not assignable toAnimal.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)I literally don't see the check for
Dog extends Tanywhere in your example. And,Animal & !Dogis literally assignable toAnimal.type Animal = Cat | Dog | Horse; type NotDog = Animal & !Dog; // equivalent to Cat | Horse declare const cat: Cat; const notDog: NotDog = cat; const animal: Animal = notDog;
It is not valid to conclude that, just because
Tis not a subset ofDog, thatTcontains no dogs.We're not concluding that
Animalcontains no dogs. We're concluding thatAnimal & !Dogcontains no dogs. There's a very big difference.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.You're arguing that
!(T extends Dog)always impliesT & !Dog, which is not true, becauseTmight be (and is, in my example)Animal.Yes,
!(T extends Dog)always impliesT & !Dog, even ifTisAnimal. It's impossible to assign aDogtoNonDog<Animal>.type Cat = "cat"; type Dog = "dog"; type Horse = "horse"; type Animal = Cat | Dog | Horse; type NonDog<T> = T extends Dog ? never : T; const dog: Dog = "dog"; const nonDog: NonDog<Animal> = dog; // ^^^^^^ // Type '"dog"' is not assignable to type '"cat" | "horse"'. (2322)
Dog | Cat | Horseis not a subset ofDog, but it still contains dogs.What does that have to do with anything?
Cat | Horsecontains no dogs, so you've changed the meaning of the original type by narrowing it.I haven't. You literally can't assign a
DogtoNonDog<Animal>.Aadit M Shah (@aaditmshah) counterexample:
Open in Playgroundtype Cat = { cat: true }; type Dog = { dog: true }; type Horse = { horse: true }; type Animal = Cat | Dog | Horse; type NonDog<T> = T extends Dog ? never : T; const dog = { dog: true, horse: true } satisfies Animal & Dog; const nonDog: NonDog<Animal> = dog;
Reacted by Aadit M Shahsomebody1234 Your counterexample has errors.
type Cat = { cat: true }; type Dog = { dog: true }; type Horse = { horse: true }; type Animal = Cat | Dog | Horse; type NonDog<T> = T extends Dog ? never : T; const dog: Dog = { dog: true, horse: true }; // ^^^^^ // Object literal may only specify known properties, and 'horse' does not exist in type 'Dog'. (2353) const nonDog: NonDog<Animal> = dog; // ^^^^^^ // Type 'Dog' is not assignable to type 'Cat | Horse'.(2322)
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.
Aadit M Shah (@aaditmshah) it doesn't have errors, you modified the code so that it produced an error
hmm... i guess the idea doesn't quite check out
my bad.Aadit M Shah (@aaditmshah) Here is a proper incorrect case:
Open in Playgroundtype Cat = { animal: true; cat: true }; type Dog = { animal: true; dog: true }; type Horse = { animal: true; horse: true }; type Animal = { animal: true }; type NonDog<T> = T extends Dog ? never : T; const dog: Dog = { animal: true, dog: true }; const animal: Animal = dog; const nonDog: NonDog<Animal> = animal;
For arbitrary
TandU, "Tis not a subset ofU" does not imply "Tis a set which contains no elements fromU".T & !Umeans the latter.Reacted by Joe Calzaretta- locked as resolved and limited conversation to collaborators
on Oct 22, 2025
π Search Terms
π Version & Regression Information
This is the behavior in every version I tried, and I reviewed the FAQ for entries about narrowing.
β― Playground Link
https://www.typescriptlang.org/play?target=99&jsx=0#code/C4TwDgpgBAKgFgVwHYGsA8BBAfFAvFACgEo8cMBuAKEtEigDkB7JAMWQGNgBLZzHfDFAgAPYBCQATAM5Q2STjyRQA-FCQQAbhABOUAFxQK1WtACiwsNr55YiVNYA+DZnIW9sVSu2ZTgQjQCGADYIAWI2fAQilgbmlnxEBoK4WJRQUCaMAGZCFrq4BVAARFkc3MxFKrmWxPrV2lTpAPRN6W3tHZ1dAHq93WlQLVAAtKNj4xOTU9Mzs3PzUwND8Fwy0doQUlKKUKtqjH7swUEBAEZBEAB0S63pTH7HUN5IvlzACOLAMtkZ4NAA5PBkOhsFAnAQmKwyoprAAyWTQ5hEf5QAIbJ7HM4Xa7NW5tGB-KD-SGucpIOEI+RklFwAIyJCMDFBIJQbYAcyQYQQGykl0IACYAMwAFgAnEQbiMFtKZbK5TNJQQwGiAgBbCBibQkdYGIH2UHgkmI8mg+GkxQS3FS+U2212iaUIA
π» Code
π Actual behavior
The parameter
expr, which has the typeExpr<A>, is being narrowed by thetypeof expr === "function"condition. When the condition istrue, the type ofexpris narrowed toThunk<A> | (NonFunction<A> & Function). When the condition isfalse, the type ofexpris narrowed toNonFunction<A>.π Expected behavior
When the condition is
true, the type ofexprshould be simplified to justThunk<A>.The challenging step to understand is step number 4 where we invoke the law of non-contradiction to simplify the type
A & Functiontonever. The law of non-contradiction states that some propositionPand its negation can't both be true. In our case, the proposition isFunction. SinceA & Functionis in the else branch of the conditionalA extends Function, it implies thatAis not a function. Hence,AandFunctionare contradictory. Thus, by the law of non-contradiction we should be able to simplify it tonever.In plain English,
NonFunction<A> & Functionis a contradiction. A type can't both be aFunctionand aNonFunction<A>at the same time. Hence, the type checker should simplifyNonFunction<A> & Functiontonever.At the very least,
NonFunction<A> & Functionshould be callable. We shouldn't get ats2349error.Additional information about the issue
I understand that as TypeScript is currently implemented,
NonFunction<A> & Functiondoesn't simplify toneverfor all possible typesA. For example, consider the scenario whenAisunknown.This seems to be because in step number 2 we're simplifying
unknown extends Function ? never : unknownto justunknown. Instead, we should simplify it tounknown & !Functionwhere 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.At the very least, this seems to suggest the need for some sort of negation type.