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 changes the generic type, "resolves" it immediately #52394

Open
judehunter opened this issue Jan 24, 2023 · 12 comments
Open

satisfies changes the generic type, "resolves" it immediately #52394

judehunter opened this issue Jan 24, 2023 · 12 comments
Labels
Bug A bug in TypeScript Help Wanted You can do this
Milestone

Comments

@judehunter
Copy link

Bug Report

It seems that using satisfies on a value that is bound to a generic type, in some way resolves the generic type at that place.

const foo = <T extends 1 | 2>(bar: T) => {
  // PROBLEM: a and b should have the same type.
  const a = bar satisfies any; // using any type that doesn't use the generic T here creates this problem
  //    ^? 1 | 2
  const b = bar;
  //    ^? T extends 1 | 2
}

Playground link

🔎 Search Terms

satisfies changes type, generic, widening

🕗 Version & Regression Information

Using 4.9.4 now with satisfies and noticed this.

  • This changed between versions 4.9 and 4.8 when satisfies was introduced
  • This is the behavior in every version I tried, and I reviewed the FAQ
  • I was unable to test this on prior versions because satisfies didn't exist

⏯ Playground Link

Playground link

🙁 Actual behavior

a and b have different types as seen in the playground

🙂 Expected behavior

satisfies should be a no-op

@RyanCavanaugh
Copy link
Member

That's weird, but also, what reason would you write this code in the first place? Something of type T can at most least be its constraint.

@RyanCavanaugh RyanCavanaugh added Bug A bug in TypeScript Help Wanted You can do this labels Jan 24, 2023
@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Jan 24, 2023
@judehunter
Copy link
Author

@RyanCavanaugh
For instance, let's say I wanted to write a function that returns the narrowest type possible, but also make sure that the returned type conforms to some more general type.

type MyType = {
  a: string;
  b: 'a' | 'b' | 'c' | 'd' | 'e';
};
const foo = <T extends 'a' | 'b' | 'c'>(bar: T) => ({
  a: bar,
  b: bar,
}) satisfies MyType;
// I should now have the return type be {a: T; b: t},
// (but instead it's {a: 'a' | 'b' | 'c'; b: 'a' | 'b' | 'c'}, as if it was resolved immediately)
// while also being sure that if any API changes (like MyType) in the future, I will know exactly what needs to change

Playground

What's worth noting is that this bug seems to only happen in some scenarios of T extends. Possibly only when T extends a union type, but not sure, it's hard to track down. The following works as expected:

type MyType = {
  a: string;
  b: string;
};
const foo = <T extends string>(bar: T) => ({
  a: bar,
  b: bar,
}) satisfies MyType;

const result = foo('abc')
//    ^? {a: 'abc'; b: 'abc'}

Playground

@Andarist
Copy link
Contributor

It's this call to getNarrowableTypeForReference that "resolves" the type here. The comment within this function also explains why unions are affected here.

The simplest possible fix that comes to my mind is to just avoid this "resolving" when we are satisfies but that potentially would also have detrimental effects (if I'm not mistaken). We probably should check the target of satisfies against the result of this getNarrowableTypeForReference but we shouldn't return that "resolved" type. Since this happens within checkidentifier (before the type is returned to the checkSatisfiesExpressionWorker - it might require some more code juggling to make it work like this. Or maybe it's just a matter of obtaining the exprType without resolving it (when it's generic etc) and returning that from checkSatisfiesExpressionWorker after checking the assignability. @RyanCavanaugh how does it sound?

@RyanCavanaugh
Copy link
Member

Uh, I have no idea. It's going to be sketchy to try to change this because we don't have much real-world coverage on satisfies yet, but if something does need to change it might be better to rip off the band-aid earlier rather than later.

@Andarist
Copy link
Contributor

Interestingly, a similar thing happens for auto types:

const foo = <T extends 1 | 2>(bar: T) => {
  let test1
  test1 = bar
  test1 // actual: `1 | 2`, expected: `T extends 1 | 2`
}

@judehunter
Copy link
Author

judehunter commented Jan 25, 2023

@Andarist just tested the auto types you just mentioned on different versions of ts on the playground. It works correctly on v4.2.3 (type is T extends 1 | 2), but incorrectly on v4.3.5 (type is 1 | 2)

If that helps

@Andarist
Copy link
Contributor

It makes sense since this getNarrowableTypeForReference call has been introduced in #43183 and that was released in 4.3. I assume that this same call is responsible for "resolving" this generic type too eagerly. cc @ahejlsberg

@flavianh
Copy link

flavianh commented Feb 6, 2023

I think I stumbled on the same case:

type BoxState = 'open' | 'closed'

type Box = {
  boxState: BoxState;
  boxedObject: unknown
}


function boxFactorySafe<BS extends BoxState>(boxState: BS, boxedObject: unknown) {
  return {
    boxState,
    boxedObject
  } as const satisfies Box;
}

const safeBoxedObject = boxFactorySafe(
  'open',
  'some value'
)

// Tests
type testCasesSafe = [
  // Fails because satisfies "destroys" the boxState const value -- why?
  Expect<Equal<typeof safeBoxedObject['boxState'], 'open'>>
]

Link to playground

@Andarist
Copy link
Contributor

Andarist commented Feb 6, 2023

It definitely looks similar, I verified that this satisfies Box "casts" your BS generic to its constraint (BoxState) and that shouldn't happen.

@conorbrandon
Copy link

Is this another example?

Slightly different because satisfies is used on the returned value of a function:

type Obj<S extends string> = { s: S };
const thing = <S extends string>(o: Obj<S>) => {
  return o;
};
const yay = thing({ s: "hi" });
//    ^? const yay: Obj<"hi">
const hmm = thing({ s: "hi" }) satisfies Obj<string>;
//    ^? const hmm: Obj<string>

Link to Playground

Admittedly, the above is an absolutely contrived example and does not differ from v4.9.5 and v5.0.4 in the Playground. However, this "widening" behavior does not occur in all scenarios. For example, this is fine:

type Str<S extends string> = S;
const thing = <S extends string>(o: Str<S>) => {
  return o;
};
const yay = thing("hi");
//    ^? const yay: "hi"
const alsoYay = thing("hi") satisfies Str<string>;
//    ^? const alsoYay: "hi"

Link to Playground

From the docs, my understanding of satisfies is "The new satisfies operator lets us validate that the type of an expression matches some type, without changing the resulting type of that expression" (emphasis mine), hence why I find this behavior confusing.

@rimunroe
Copy link

I just ran into something that feels very similar, where using satisfies causes types to narrow

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'.

I filed this as #55189, but feel free to close if it's a duplicate

@Andarist
Copy link
Contributor

The issue that you filed feels different than this one to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Bug A bug in TypeScript Help Wanted You can do this
Projects
None yet
Development

No branches or pull requests

6 participants