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

satisfies operator changes the type of an expression #55189

Closed
rimunroe opened this issue Jul 28, 2023 · 11 comments
Closed

satisfies operator changes the type of an expression #55189

rimunroe opened this issue Jul 28, 2023 · 11 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@rimunroe
Copy link

rimunroe commented Jul 28, 2023

Bug Report

Applying the satisfies operator to an expression can change the type of that expression.

The release notes for the satisfies operator say the following:

The new satisfies operator lets us validate that the type of an expression matches some type, without changing the resulting type of that expression.

However, the type of the expression appears to be affected by the use of the operator.

This seems possibly related to #52394

🔎 Search Terms

satisfies, changes type, declaration

🕗 Version & Regression Information

Found in

  • 4.9.5
  • 5.0.4
  • 5.1.6
  • 5.2.0-beta

This is the behavior in every version I tried, and I reviewed the FAQ for any entries related to declarations, expressions, narrowing, etc. and couldn't find anything

⏯ Playground Link

Playground link with relevant code

💻 Code

type Foo = { a: boolean; }

const f = {a: true};
f.a = false; // no problem!

const g = {a: true} satisfies Foo;
g.a = false; // uh oh
//^ Type 'false' is not assignable to type 'true'.

🙁 Actual behavior

The type of g is { a: true; } when satisfies Foo is used, and { a: boolean; } if it's omitted.

🙂 Expected behavior

The type of g is { a: boolean; } regardless of whether satisfies Foo is used.

@Andarist
Copy link
Contributor

This is very specific to boolean. The "problem" with boolean is that it's actually a union (true | false). So what you see here, I think, is roughly equivalent to the same situation with a string like here:

const foo = { prop: 'a' } satisfies { prop: 'a' | 'b' }
foo.prop = 'b' // error

IIRC, there are some places in the compiler that special case boolean for this reason so maybe this issue can be classified as a bug / possible improvement but I'm not sure.

@rimunroe
Copy link
Author

rimunroe commented Jul 28, 2023

The "problem" with boolean is that it's actually a union (true | false). So what you see here, I think, is roughly equivalent to the same situation with a string like here:

const foo = { prop: 'a' } satisfies { prop: 'a' | 'b' }
foo.prop = 'b' // error

Indeed, which is very unexpected behavior given the description of the feature.

@ahejlsberg
Copy link
Member

This is working as intended. The satisfies operator provides a contextual type for the left hand expression, and contextual types can (and are intended to) influence their target expression. In this particular case, the boolean contextual type for the expression true causes the compiler to preserve the literal type true (instead of widening it to boolean).

I suppose the issue here is that the release notes should have stated this.

@ahejlsberg ahejlsberg added the Working as Intended The behavior described is the intended behavior; this is not a bug label Jul 28, 2023
@rimunroe
Copy link
Author

As those notes seem to be the only documentation on this feature, can they be updated? This was extremely surprising to run into

@jcalz
Copy link
Contributor

jcalz commented Jul 28, 2023

EDIT: oh I see this has already been said here a minute or two ago

As I said in another issue the wording of the description of satisfies in the release notes is a bit unfortunate. It most definitely can "change" the type of the expression, via providing a contextual type. I think they mostly meant it won't obliterate the old type in favor of the new type, like an annotation or assertion would.

@rimunroe
Copy link
Author

Aside from the wording in the documentation making me think that the type of the variable wouldn't be affected by its initial value expression having satisfies after it, the thing I was struggling with was why contextual typing was producing a literal type rather than the full union type I'd have expected from either Foo or from the normal inference you'd get without satisfies.

After re-reading the original proposal thread for satisfies (and the feedback reset one) I was still confused, but time I noticed some stuff that prompted me to search for discussions about contextual typing unions. I stumbled on the following extremely relevant part of a comment:

when a primitive literal is contextually typed by a union containing that literal's corresponding type, then it retains its actual literal type instead of widening to its parent primitive

I had no idea that was the case. As a side note, I was originally confused about why @Andarist was bringing up the union nature of booleans, but now it seems they were probably referring to this behavior. I've looked around a bit more but haven't been able to find a clear explanation of why contextually typing unions behaves this way. I've been reading and rereading stuff for a while though so my eyes may have glazed over a bit.

If someone can provide more info about this or point me at some documentation1 or relevant discussion about this that I've missed I'd appreciate it. I suppose it's like a lot of things in the type system where it's something that makes possible the things that I do daily but that I've never noticed it because in other cases it's what I want. I found it totally surprising and unintuitive in this case though.

I'm satisfied that this behavior isn't a bug now, so I'm closing the issue. It would be nice if folks would consider a documentation update to make it clearer that because satisfies provides contextual typing its presence in a declaration can affect the type of a variable.

1. I didn't notice anything in the existing handbook page on type inference & contextual typing which covered this.

@fatcerberus
Copy link

fatcerberus commented Jul 29, 2023

@rimunroe Here's a good illustrative example:

type Fooey = { foo: "foo" | "bar" };
const err = { foo: "qux" } satisfies Fooey;  // error, good
const ok = { foo: "bar" } satisfies Fooey;  // no error, also good

Normally, properties in an object literal that are initialized with a primitive literal are widened when the type of the object is inferred; in this case, ok.foo normally widens to string which would then fail the satisfies check. In order for satisfies to work in conjunction with literal types, this widening must be prevented. The way that's done here is through contextual typing.

@rimunroe
Copy link
Author

rimunroe commented Jul 29, 2023

@rimunroe Here's a good illustrative example:

type Fooey = { foo: "foo" | "bar" };
const err = { foo: "qux" } satisfies Fooey;  // error, good
const ok = { foo: "bar" } satisfies Fooey;  // no error, also good

Normally, properties in an object literal are widened when the type of the object literal is inferred; in this case, ok.foo normally widens to string which would then fail the satisfies check. In order for satisfies to work in conjunction with literal types, this widening must be prevented. The way that's done here is through contextual typing.

Welp I feel pretty dense. I was typing out a whole response about how it felt like this was just a subset of the problem I'd already shared and then suddenly your comment clicked and several other comments made more sense. Just to double check my understanding and in case anyone else comes across this in the future and struggled the same amount, is the following correct?

In my specific case with boolean properties this feels confusing because the widened type you'd normally get, { a: boolean; }, is coincidentally the type specified by Foo. For a union of other literal types, widening the type would produce a primitive type like string, which would not be assignable to any union of literal types because an infinite set of values satisfy string and a union of literal types is by definition finite. So you'd encounter this surprise anytime you used a value of true, false, or something from an enum and checked against a type which specified the widened-but-finite general type (which is what would have been inferred if satisfies wasn't present).

@jcalz
Copy link
Contributor

jcalz commented Jul 29, 2023

Also see #10676 for the basic rules for when literal types are and are not widened

@fatcerberus
Copy link

@rimunroe Yeah, that’s about the gist of it. Hypothetically, if string was a union of all possible strings (impossible because it’s infinite), you’d get the same behavior as with boolean which is treated as true | false.

@rimunroe
Copy link
Author

rimunroe commented Jul 29, 2023

Thank you so much to everyone for the patient explanations!

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

5 participants