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

Inequivalent intersection types are treated as equivalent #42204

Closed
mheiber opened this issue Jan 4, 2021 · 6 comments
Closed

Inequivalent intersection types are treated as equivalent #42204

mheiber opened this issue Jan 4, 2021 · 6 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@mheiber
Copy link
Contributor

mheiber commented Jan 4, 2021

Bug Report

Inequivalent intersection types are treated as equivalent.

This issue seems to arise for intersections of function types with overlapping domains. In these cases, order matters when it comes to calculating return types based on arguments, but does not seem to matter for assignability.

cc @ilya-klyuchnikov, who helped find this

🔎 Search Terms

intersection, intersection type, assignability, equivalence

🕗 Version & Regression Information

4.1.3
Nightly–I think 4.2.0-dev.20210104
4.0.5
3.9.7
3.8.3
3.75
3.6.2
3.5.1
3.333

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

⏯ Playground Link

Playground link with relevant code

💻 Code

type Z1 = ((thing: string) => string)
          & ((thing: unknown) => number);

type Z2 = ((thing: unknown) => number)
          & ((thing: string) => string);

declare const z1: Z1;
declare const z2: Z2;

// the behavior of z1 and z2 is distinct
const t1: string = z1("");
const t2: number = z2("");

// yet z1 and z2 are interassignable
const test1: Z1 = z2;
const test2: Z2 = z1;

// and Z1 and Z2 extend each other
type Equivalent<T, U> = T extends U ? U extends T ? true : never : never;
const testType: Equivalent<Z1, Z2> = true;

🙁 Actual behavior

z1 (of intersection type Z1) and z2 (of intersection type Z2) have different behavior: the return types of z1("") and z2("") are distinct. Yet z1 and z2 are interassignable and TS thinks Z1 and Z2 extend each other.

🙂 Expected behavior

In the code example, I'd expect either t1 and t2 to have the same type or for Z1 and Z2 to be inequivalent (not inter-assignable, not mutually-extending).

Note: I know the spec is archived, but it does say that B&A and A&B are not always equivalent. If that's still the case, then I'd expect Z1 and Z2 to be inequivalent in the code example above.

@mheiber
Copy link
Contributor Author

mheiber commented Jan 4, 2021

I'm not blocked by this issue and from my point of view it seems low-priority.

@DanielRosenwasser
Copy link
Member

DanielRosenwasser commented Jan 4, 2021

I believe the same issue applies regardless of intersection types - for example, if you just have two otherwise identical types with re-ordered signatures. I'd say this is either a design limitation (we might want to fix it if there was a clear non-breaky technique to do this right) or just working as intended.

@mheiber
Copy link
Contributor Author

mheiber commented Jan 4, 2021

Thanks for taking a look.

"I believe the same issue applies regardless of intersection types"

I can confirm that the behavior is the same with call signatures. The playground shows the same behavior if Z1 and Z2 are defined as follows:

interface Z1 {
    (thing: string): string
    (thing: unknown): number
}

interface Z2 {
    (thing: unknown): number
    (thing: string): string
}

"I'd say this is either a design limitation (we might want to fix it if there was a clear non-breaky technique to do this right) or just working as intended."

It's a soundness hole. I haven't been bitten by it, but I can see it causing real bugs. A change that would be backwards-compatible for code that isn't buggy would be to consider order in checkTypeRelatedTo in cases where order matters.

The "cases where order matters" might just be intersections and interfaces where domains overlap but codomains do not. I couldn't concoct a version of this bug for mapped types.

@RyanCavanaugh
Copy link
Member

I don't find these type definitions to be coherent, so don't really think we can make true statements about them.

Z2 (from either example)'s mere existence is a soundness hole absent any other behavior, since it fails to do the right thing in the presence of string -> unknown aliasing. This is just sort of the trade-off you have to accept with type constructors -- you can & any two arbitrary types and we'll try to piece together some meaning of what you tried to mean, which will frequently be right, but we don't have the ability to check those for coherence or directly prevent those conflicts.

@mheiber
Copy link
Contributor Author

mheiber commented Jan 4, 2021

Z2 (from either example)'s mere existence is a soundness hole

If backwards-compat weren't a concern, I suppose a good solution would be to forbid (A -> T) & (B -> U) when B and A might overlap.

I do suspect checkTypeRelatedTo could be tweaked to not treat Z1 and Z2 as equivalent, but question whether the code complexity is worth it to handle this edge case.

Sounds like this is working as designed–shall I close the issue?

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Jan 6, 2021
@typescript-bot
Copy link
Collaborator

This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

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

4 participants