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

Unexpected type failure #57775

Closed
erik-kallen opened this issue Mar 14, 2024 · 14 comments
Closed

Unexpected type failure #57775

erik-kallen opened this issue Mar 14, 2024 · 14 comments
Labels
Not a Defect This behavior is one of several equally-correct options

Comments

@erik-kallen
Copy link

πŸ”Ž Search Terms

Couldn't think of any :(

πŸ•— Version & Regression Information

  • This is the behavior in every version I tried

⏯ Playground Link

https://www.typescriptlang.org/play?#code/C4TwDgpgBAKlC8UDeUBmB7dAuKBnYATgJYB2A5gDRQAmAhsLTigB44kCuAtgEYQFQBfQVAA+yNJhwBybrQJSqdBkyFCxKDNigzaALwU16jZKoDcAKHOp2JAMbAi6EmgCMACjDTZ80dtn6ASmRzKFCodABrNw1JKDBFIxUBAPMBS2s7BydwqOAcGCCkVPMgA

πŸ’» Code

type T = { foo: string, data: { x: number } } | { foo: 'bar', data: {} } | { foo: 'baz', data: {} };

function f1(p: 'bar' | 'baz') {
    ok({ foo: p, data: {}})
}

function ok(t: T) {
}

πŸ™ Actual behavior

I get an error message

Argument of type '{ foo: "bar" | "baz"; data: {}; }' is not assignable to parameter of type 'T'.
  Types of property 'data' are incompatible.
    Property 'x' is missing in type '{}' but required in type '{ x: number; }'

πŸ™‚ Expected behavior

No errors

Additional information about the issue

If I change the parameter to p: 'bar', it works. If I change it to p: 'baz', it works. If I remove the { foo: string; ... } option from the union type, it works. But something confuses TS with this specific combination.

@erik-kallen
Copy link
Author

I don't think this is true, because if I remove the string case it is suddenly valid. IOW, this code produces no error, even though it would have the same issue with { foo: "bar" | "baz" } not being assignable to { foo: "bar" } | { foo: "baz" }.

type T = { foo: 'bar', data: {} } | { foo: 'baz', data: {} };

function f1(p: 'bar' | 'baz') {
    ok({ foo: p, data: {}}) // This is accepted without issue
}

function ok(t: T) {
}

@snarbies
Copy link

{ foo: "bar" | "baz" } does appear to be considered structurally equivalent to { foo: "bar" } | { foo: "baz" }. What of the fact that T.foo is "bar" | "baz" | "string"? I'd imagine the fact that that can simplify to string could muddy the waters.

(T & {foo: 'bar' | 'baz'})['data'] resolves to {} | {x: number}. I don't think that in theory you could exclude the {x: number}. someT.foo could have the value bar or the value baz and still be a { foo: string, data: { x: number } }. This isn't a proper discriminated union.

@erik-kallen
Copy link
Author

Whether or not it is a "proper" discriminated union, both { foo: 'bar', data: {} and { foo: 'baz', data: {} } are valid values of the type. The reason I think it is a bug rather than a misfeature is that it seems to me the compiler gets confused by the (unrelated) string case.

@erik-kallen
Copy link
Author

erik-kallen commented Mar 14, 2024

It doesn't really make sense to me that there exist any types T1 and T2 such that there are values that are assingable to T1 but not to T1 | T2

@fatcerberus
Copy link

@ahejlsberg Remember when I wrote this comment #57231 (comment) saying such types probably exist in the wild? Yeah, someone pick up that phone because I called it.

@RyanCavanaugh RyanCavanaugh added the Not a Defect This behavior is one of several equally-correct options label Mar 15, 2024
@RyanCavanaugh
Copy link
Member

This type doesn't make any sense; there's no way to soundly access { x: number }, so it's the same as

type T = { foo: string, data: { } }

TypeScript doesn't have negated types and efforts to mimic them will, of course, not succeed.

@erik-kallen
Copy link
Author

Please don't tell me that my stuff does not make sense, you know nothing about my code and you shouldn't pretend to do it.

Our actual types are something like this:

type FrontendEvent = { type: 'click'; data: {} } | { type: 'navigate'; data: {} }
type BackendEvent = { type: string; data: { __nominativeType: true } }
type Event = FrontendEvent | BackendEvent

function track(event: Event) {
  sendToOurTracker(event);
}

and we want calls to succeed for all event types, but no one is actually looking inside the data after we have converted it to an Event.

Can we work around this? Yes we can (for example by saying BackendEvent = { type: string & { __fixTypeScriptIssue: true }}

But still, as I wrote above: It doesn't make sense that there are any types T1 and T2 such that there is any value that is assignable to T1 but not to T1 | T2.

@fatcerberus
Copy link

fatcerberus commented Mar 15, 2024

Please don't tell me that my stuff does not make sense, you know nothing about my code and you shouldn't pretend to do it.

You wrote code thinking it would work a certain way and it doesn't. This is because the type as written doesn't make sense under the rules of the type system. We have no doubt the code makes sense to you, but it doesn't make sense to TypeScript under the rules of the type system as designed. That's what Ryan is saying.

But still, as I wrote above: It doesn't make sense that there are any types T1 and T2 such that there is any value that is assignable to T1 but not to T1 | T2.

I don't know what you mean by this because there indeed is no such type. In fact that's the root of your problem: { foo: "bar" } is also a legal { foo: string }. so just knowing that typeof p.foo === 'string' isn't enough information to rule out the first two cases, and thus TS won't do so because it would be unsound.

What you'd need for this to work properly is a way to express

type T = { foo: string & not "bar" & not "baz", data: { x: number } };

AKA negated types. #4196

@typescript-bot
Copy link
Collaborator

This issue has been marked as "Not a Defect" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@typescript-bot typescript-bot closed this as not planned Won't fix, can't repro, duplicate, stale Mar 18, 2024
@erik-kallen
Copy link
Author

But still, as I wrote above: It doesn't make sense that there are any types T1 and T2 such that there is any value that is assignable to T1 but not to T1 | T2.

I don't know what you mean by this because there indeed is no such type.

There is:

type T1 = { foo: 'bar', data: {} } | { foo: 'baz', data: {} };
type T2 = { foo: string, data: { x: number } };

let x: 'bar' | 'baz' = (window as any).x;
const x1: T1 = { foo: x, data: {} }; // This is OK
const x2: T1 | T2 = { foo: x, data: {} }; // But this is not. So the literal is assignable to T1 but not to T1 | T2

@RyanCavanaugh this is the reason I reported it. I know what I'm doing is a little fishy, but there is a value that is assignable to a type T1 but not to T1 | T2

@snarbies
Copy link

snarbies commented Mar 18, 2024

there is a value that is assignable to a type T1 but not to T1 | T2

The problem is the type T1 | T2 is inherently ambiguous. You could use a type assertion before assigning (or any moral equivalent), but where there is otherwise no proper discriminant, Typescript can't distinguish whether your value is a T1 or a T2, and thus can't validate it as one or the other. In order to satisfy the type, you need to satisfy all constituent union members.

@erik-kallen
Copy link
Author

From an ideal perspective, there is no question about whether the value satisfies T1 | T2, it 100% does. Why? Because it satisfies T1, and all types that satisfy a constraint T1 should (as @RyanCavanaugh agreed with earlier), satisfy the constraint T1 | T2 for all types T2.

Is this worth fixing in TS? I don't know. But it absolutely means that TS deviates from an ideal constraint checker.

@snarbies
Copy link

To be clear, this isn't an assignability issue. The value is assignable to the type T1 | T2:

type T1 = { foo: string, data: { x: number } }
type T2 = { foo: 'bar', data: {} } | { foo: 'baz', data: {} }
type T =  T1 | T2 ;

function ok(t: T) { }

declare const p: 'bar' | 'baz'

// Validation of the value
const value: T2 = { foo: p, data: {}};

// Assignability of the value
ok(value);

// Does not validate
const value2: T = { foo: p, data: {}};

Where you get hung up is before assignment... This is a validation issue.

Think like excess property checks, except there is a stronger case for an error here because the value can absolutely be construed as a malformed T1. If you can't distinguish a T1 from a T2, you can't soundly assume that what you have isn't a malformed T1. The problem goes away when Typescript does not have to distinguish between a T1 and a T2 itself. And I'm not making the case that this is the most desirable behavior (typescript sometimes values practicality over soundness), but the logic is coherent.

It's not necessarily uncommon for a distinction represented in the types to correlate to another distinction not represented in the types (e.g. brand types). Accepting a value where you can't validly rule out a malformed T1 means an invalid value could move through your plumbing and back out and then violate an invariant of external code.

Is it practical? I don't know. I see the utility of the type you're trying to portray here, but there is still a possible logic error being identified, and a very subtle one at that, so while I might be irritated too if I were bit by this the way you were, ultimately I think this is the desirable behavior and I certainly couldn't find my way to labeling it as incorrect.

@erik-kallen
Copy link
Author

Thank you, that was a good explanation.

you can't soundly assume that what you have isn't a malformed T1

This is probably for the better, then. It's probably better to reject some valid programs than to accept more invalid ones.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Not a Defect This behavior is one of several equally-correct options
Projects
None yet
Development

No branches or pull requests

5 participants