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

Impossible intersection is supposedly never but any can still be assigned to it #53027

Open
benjaminjkraft opened this issue Feb 28, 2023 · 6 comments
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript

Comments

@benjaminjkraft
Copy link

Bug Report

🔎 Search Terms

intersection assign never

🕗 Version & Regression Information

This is the behavior in every version I tried (including nightly), and I reviewed the FAQ for relevant entries. But really this is a loose end left by #36696; prior to that the impossible intersection (AB below) was not never at all.

⏯ Playground Link

link

💻 Code

(modified from the example in #36696)

type A = { kind: 'a', foo: string };
type B = { kind: 'b', foo: number };

type AB = A & B;  // never, supposedly

// @ts-expect-error: Type 'any' is not assignable to type 'never'.
const n1: never = {} as any
// but this is ok
const n2: AB = {} as any

🙁 Actual behavior

any is assignable to AB, despite the fact that any is not assignable to never and AB is supposedly never (per both #36696 and hovering it)

🙂 Expected behavior

any should not be assignable to AB

@RyanCavanaugh
Copy link
Member

Is this behavior observable in a way that matters?

@benjaminjkraft
Copy link
Author

Two problems I know of that seem to be caused by this.

First, it means assigning to the quasi-never type AB gives errors on each field rather than on the object as a whole, which makes those errors harder to understand IMO:

const n3: AB = {
    // @ts-expect-error: Type 'number' is not assignable to type 'never'.
    a: 3,
}

Second, it means that Omit<AB, K> gives an incorrect type (even when using the conditional type trick such that it does work with literal never:

type DistributedOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never
// @ts-expect-error: Type '{}' is not assignable to type 'never'.
const n4: DistributedOmit<never, "bogus"> = {}
// allowed
const n5: DistributedOmit<AB, "bogus"> = {}

(To be clear, these are all simplified from something I ran into in real code.)

Full playground here.

@RyanCavanaugh
Copy link
Member

The presented issue was that you don't want any to be assignable to AB, though, which seems totally unrelated.

@benjaminjkraft
Copy link
Author

benjaminjkraft commented Feb 28, 2023

Sure, I guess that was intended as the simplest example of "this type is supposedly never but is not in fact equivalent to never". Apologies if I misunderstood how these are related; if it's more useful, I'm happy to make a new issue framed around those examples.

@RyanCavanaugh
Copy link
Member

Yeah, the exact manifestation / what you care about matters. These are all orthogonally addressable, in theory.

The PR notes that this is a consequence of the reduction being deferred:

In #31838 we reduce empty intersections to never immediately upon construction. However, in this PR we have to defer the determination because it requires us to resolve the types of properties in the constituent object types. Specifically, when two or more constituent object types have properties with the same name, we need to resolve the types of those properties in order to determine if they're disjoint discriminants, but doing so during intersection construction can easily cause circularities. So, instead of reducing intersections upon construction, we reduce them immediately before accessing members, relating them to other types, or converting them to their string representations in diagnostics and quick info.

I'm not sure if there are any cures on the table which are better than the disease here. Basically, not all of these can be true at once:

  • Nothing (except never) is assignable to never
  • S is assignable to T & U if S is assignable to T and S is assignable to U
  • any is assignable to anything that isn't never
  • Just mentioning the type A & B won't introduce new circularity errors into your code

so we have to park the inconsistency somewhere. It's extremely rare to see a zero-order intersection that reduces to never in practice, so choosing that scenario as the one to be the most consistent at the expense of violating other invariants seems like a bad choice. I'd be curious to know more about how you ended up passing around an unreduced intersection that was actually never; these typically only happen in higher-order scenarios where they get reduced out of the final union before they can land in usercode and cause problems.

IMO probably the most convincing example that something is wrong is something like this:

// x is effectively { [s: string]: never }
type X = Omit<A & B, "nada">;
const x: X = { }; // Allowed initialization, maaaaybe ok per definition of X?
const s: string = x.anything; // Allowed property access, sad
console.log(s.toLowerCase()); // Crash, sad.

but the initialization of X only succeeds if the initializer is exactly { }; anything else would fail, same as here

// @ts-expect-error: Type '{}' is not assignable to type 'never'.
const n4: DistributedOmit<never, "bogus"> = {}

which is sort of a niche problem.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. labels Mar 3, 2023
@benjaminjkraft
Copy link
Author

I'm not sure if there are any cures on the table which are better than the disease here. Basically, not all of these can be true at once:

  • Nothing (except never) is assignable to never
  • S is assignable to T & U if S is assignable to T and S is assignable to U
  • any is assignable to anything that isn't never
  • Just mentioning the type A & B won't introduce new circularity errors into your code

Got it -- I don't know that I understand all the details of the implementation issues here but I see why it's useful for you to know which inconsistencies are the most relevant. In the case where this came up for me, I think the most confusing thing is that the errors are on the fields rather than the whole object? Definitely the fact that any is assignable to this type is the least relevant to me.

I'd be curious to know more about how you ended up passing around an unreduced intersection that was actually never; these typically only happen in higher-order scenarios where they get reduced out of the final union before they can land in usercode and cause problems.

The full code at issue is quite complicated, but let me see if I can summarize without eliding anything that might be relevant.

The idea is basically we have some types which represent our database, and many of those have a field parentTable which is some union of other database tables and parentId which is then the ID of some row of one of those tables. Also, some of those types are discriminated unions of further subtypes. We generate a bunch of test data factories which accept basically a subset of the database fields, and fill in the rest with defaults (so basically they accept Omit<T, ...> and return T).

Then we generate further factories which, given some such database value, generate valid children of that database value, automatically setting parent*. So those want to accept only those subtypes of the type we are generating that have the right parent table. The impossible intersection arises when you try to call one with an invalid subtype; the arguments are the type similar to Omit<AB, "kind">.

Here's all of that in playground.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

2 participants