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

Loss of empty type constraint in generics #36124

Closed
soul-codes opened this issue Jan 10, 2020 · 4 comments
Closed

Loss of empty type constraint in generics #36124

soul-codes opened this issue Jan 10, 2020 · 4 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@soul-codes
Copy link

TypeScript Version: 3.8.0-dev.20200109

Search Terms:
generic, empty type, lost constraint

Code

var cannotBeNull: {} = null // Type Error. 
// Given the above behavior of null not being considered a subtype of {}...

type MustNotBeNull<T extends {}> = T;
const foo: MustNotBeNull<null> = null; // Type Error
// ...and this above (so far consistent) behavior...

type AlsoMustNotBeNull<T> = MustNotBeNull<T> // ...then, this shouldn't be allowed, but it is!
const bar: AlsoMustNotBeNull<null> = null;

Expected behavior/Actual behavior:
As annotated in the example.`

To expand, AlsoMustNotBeNull has no explicit constraint on T, whereas MustNotBeNull has an {} constraint on its T, so the former shouldn't fit the latter.

That this alias definition is allowed suggests that the fallback constraint is also {} (which I thought wasn't the case). And even if there was such a fallback constraint of {}, I would have expected AlsoMustNotBeNull<null> not to be a valid type.

An alternative inference would be that there is a bug in type AlsoMustNotBeNull<T> = MustNotBeNull<T>, and the left hand side should really have been likewise constrained to extends {}.

Playground Link:
https://www.typescriptlang.org/play/?ts=3.8.0-dev.20200109&ssl=1&ssc=1&pln=9&pc=43#code/G4QwTgBAxiB2sHsAuAhApgOQK4BscC4IBvAXwgF4JZccIB6OiAFQE8AHNCAUTDATAB0EAFAMIAcQCWwNLAhIAFpxAAjBDIgq0CkMEn8ICAGZUaVZJrSTYAc2gJYAZ0kATNGDQuIICI6wqkdk5jYhIBcOFhQI4IAFksRyQMZHRsPAAeJgg0AA8kWRdHUIA+CmYAbmEoB0SIIwQEQnjE5NRMGnTqPFLKLpxy+kZWGJ4+MFFGcIE4L0VJItV1TgAKRwQ68HsnefzYJABKSx09finI6M4AQRw15qSU9oymHriE+7a0nEzSsSnFWQANPIFPNfAoELgXLAAORISzePAIADuniBKiwcMkmMcAEIqjU4SpwIRrrc3q1Uh0+i8+uUgA

Discussion
Just to give some context, I wanted to add a simple generic constraint that would essentially forbid "nullish" type, which led to my discovering that null cannot fit into {}.

I see that #7648 has a good discussion on expressing non-nullish type as a stand alone type, but not as a type constraint. Regardless of the discussion there, however, in my understanding so far the above example is still an unexpected behavior, because the constraint that forbids null in one generic is lost when being aliased by another generic.

@RyanCavanaugh
Copy link
Member

Well, the following things are taken to be true when evaluating type arguments and constraints:

  • If there's no explicit constraint, then null is OK ✔
  • If there is a explicit constraint, then we'd check null against it ✔
  • Unconstrained type parameters are assumed to be able to match {} 🤷‍♂️

The first two are inarguable. The last one is... not that much of a stretch, and was actually correct before the introduction of strictNullChecks.

Now that that particular cat is out of the bag, I doubt it can be put back without incurring a ton of breaking changes that wouldn't really materially benefit anyone. Inconsistencies are not per se defects so I don't think there's anything actionable here.

@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript labels Jan 10, 2020
@soul-codes
Copy link
Author

soul-codes commented Jan 15, 2020

It took me several parses and several response drafts to this, but I think I finally have it. @RyanCavanaugh maybe you would oblige by checking my inference/understanding here. Essentially:

There is a contradiction between the treatment of unconstrained generic type parameters and the relationship between {} and null.

For me come to this conclustion, I assumed that an unconstrained generic parameter can be re-written with an explicit constraint [if it isn't actually possible to do this, the whole premise breaks down], that is:

type SomeType<T> = ... // doesn't matter

is identical, for some DefaultConstraintType, to

type SomeType<T extends DefaultConstraintType> = ... // doesn't matter

To elucidate the type of DefaultConstraintType, I looked at these things:

If there's no explicit constraint, then null is OK ✔

  1. To be absolutely explicit, I read this to mean that null can be passed as a type argument to a generic whose corresponding parameter is unconstrained. This implies null extends DefaultConstraintType.

Unconstrained type parameters are assumed to be able to match {} 🤷‍♂️

  1. To be absolutely explicit, I read this to mean that an unconstrained type parameter can be used as an argument to another generic whose corresponding parameter expects {}. This implies DefaultConstraintType extends {}.

  2. According to the current behavior null and {} do not overlap. Neither extends the other and their intersection is never.

One can try this graphically, there's no way to draw a Venn diagram where all of these constraints can be satisfied.

Inkodo-1152020_83648

confused-cat

Therefore, given the current treatment of unconstrained generic parameter types, the relationship between null and {} is not well-defined. You can't say that one includes another, or that the complement of one includes another, or that one includes the complement of another, etc. Every such statement about these two types appears contradicted by one of the three statements above.

So, it's a bug as long as one expects a well-defined relationship between null and {}. But I can understand that the cost of fixing it might far outweigh the benefit of the consistency, so I can understand if we (at least for now), want to accept that there is no consistent definition of relationship between null and {}.

As for my original intention of using {} as the complement of null|undefined, I now appear to have proven myself wrong 😂. I will duly go and define:

type Concrete = number | string | boolean | symbol | object;

[Addendum]: I wonder -- if we wanted to go for consistency and drop one of the three constraints I've drawn out -- which one would be least harmful as a breaking change?.

[Addendum-dum]: I guess this status quo would not cause a bug as long as one doesn't have a program where one not rely on interaction between {} and null in some way.

@rotu
Copy link

rotu commented Feb 23, 2024

This appears to be fixed somewhere between 4.7.4 and 4.8.4

@RyanCavanaugh
Copy link
Member

Indeed, thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

3 participants