Skip to content

Inconsistent literal type widening with satisfies operatorΒ #51516

Closed as not planned

Description

Bug Report

πŸ”Ž Search Terms

satisifies tuples

πŸ•— Version & Regression Information

  • I was unable to test this on prior versions because this feature is new

⏯ Playground Link

Playground link with relevant code

πŸ’» Code

let x = ["A"] satisfies ["A" | "B"] // ["A"]
let o = { p: "A" } satisfies { p: "A" | "B" } // { p: "A" }

let y = "A" satisfies "A" | "B"  // string

πŸ™ Actual behavior

For the first two, we have the literal types preserved, while for the last one the type is widened to string

πŸ™‚ Expected behavior

literal types are preserved either in all cases or in none of the cases

Activity

  1. hameda169 commented on Nov 14, 2022

    @hameda169

    While using a constant string inside of an array or object, it's considered a const but while using it otherwhere, you should cast it as a const if you mean it.

    let y = "A" as const satisfies "A" | "B"  // string
  2. dragomirtitian commented on Nov 14, 2022

    @dragomirtitian
    ContributorAuthor

    Hamed Aghabalazadeh (@hameda169) Why is it considered a const inside an array or object ? It isn't usually so. Normal inference without satisfies or as would have it as string, even when assigned to a const.

    One could argue that since satisfies has no hint of immutability in it and since the tuple and object types are mutable, the correct types should be ["A" | "B"] and { p: "A" | "B" }

  3. fatcerberus commented on Nov 14, 2022

    @fatcerberus

    I'm a bit surprised it's inferring literal types at all. satisfies isn't supposed to change the inferred type of an expression and without it you get string[] and { p: string } for x and o respectively.

  4. dragomirtitian commented on Nov 14, 2022

    @dragomirtitian
    ContributorAuthor

    Bruce Pascoe (@fatcerberus) That was my understanding of the operator as well. The behavior is surprising.

    It also changes the type of an array to a tuple if we use the trick of constraining to [] | any[]. This is how I originally stumbled on this:

    let t = [1,2] satisfies [] | any[] // [number, number]

    Playground Link

  5. RyanCavanaugh commented on Nov 14, 2022

    @RyanCavanaugh
    Member

    We have a bit of a competing invariant problem here: The design goal of the satisfies operator is that

    • The overall expression type is the type of the asserted expression
    • The expression should be contextually typed by the asserted type

    These principles are in direct conflict when it comes to literal types and there's not a clear way to resolve the situation without just creating a different inconsistency. The root cause is that the process that causes the bare literal "A" to decay to string (widening) is different from the process that causes e.g. the literal [1, 2] to become [number, number].

    Maybe some day someone smarter than all of us can unify contextual typing and widening into a single process and fix all this (this is not sarcasm btw, I believe it's possible), but until then all we can really say is that these types are fully consistent with the different processes which produce them and that we'd actually be introducing more inconsistency by trying to undo those processes.

    For something like

    let t = [1,2] satisfies [] | any[] // [number, number]

    I think you could make a variety of arguments about what the "correct" type of t depending on the names of variables, what line of code follows, etc.. It's just not clear. The observed type of contextually-typed expressions was previously very hard to get at in TypeScript but this feature makes it fairly transparent, unfortunately, since people's mental models of how contextual type works are frequently wrong (and understandably so; they are subtle).

  6. fatcerberus commented on Nov 15, 2022

    @fatcerberus

    FWIW my mental model of contextual typing has always been β€œtype inference, but contravariant” - in other words that you infer something about the source of an assignment from its target rather than (as TypeScript usually does) vice versa, and the rules for these two styles of inference are disjoint. Full unification a la Hindley-Milner would likely make this concept go away entirely, since it’s inherently a bidirectional inference process.

    under this model though, satisfies is kind of an odd duck since the RHS of satisfies is explicitly not the assignment target by design.

  7. acutmore commented on Nov 16, 2022

    @acutmore
    Contributor

    The 4.9 announcement post says:

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

    If that is not entirely correct it feels like the documentation around the operator should elude to there being situations where the inference is impacted, maybe with links to read more for the curious reader. That said I do appreciate the need for a certain level of brevity and explaining the intuition to have for these cases is no easy task.

  8. blixt commented on Nov 16, 2022

    @blixt

    One problem I've noticed applying satisfies to our code base is that it's hard to control type widening while keeping the benefits of satisfies:

    Pre-4.9:

    export interface Config {
        color?: string
        size?: number
    }
    
    export const defaults: Config = {
        color: "#09f",
        size: 42,
    }
    
    defaults.color // string | undefined
    defaults.size // number | undefined

    4.9:

    export const defaults = {
        color: "#09f",
        size: 42,
    } satisfies Config
    
    defaults.color // string
    defaults.size // number

    Better! But, it would be nice if we could see the current defaults in the type of defaults, so I do this:

    export const defaults = {
        color: "#09f",
        size: 42,
    } as const satisfies Config
    
    defaults.color // readonly "#09f"
    defaults.size // readonly 42

    Neat! But now let's say we change the color field in Config to color?: "primary" | "secondary", the error for satisfies will be very confusing because it will no longer highlight that defaults.color has the wrong value inline, it will instead give a very long error about the resulting type not satisfying the interface:

    Screenshot 2022-11-16 at 15 40 08

    Is there a way to get the best of both worlds? (Caveat: our real types have a lot more fields and would most likely see the type changing for multiple fields at the same time, so even if the error in the screenshot is still parseable, it'd be pretty cryptic in a real-world example due to the number of fields not to mention generics/inheritance, etc)

  9. dragomirtitian commented on Nov 16, 2022

    @dragomirtitian
    ContributorAuthor

    I'm actually happy to have a way to force the inference of mutable tuples. I will be recommending this when the need arises:

    let t = [1,2] satisfies [] | any[] // [number, number]

    I was just making sure this is intentional behavior and not a bug. Like Ashley Claymore (@acutmore) mentioned, the high level description of the satisfied operator confused me into thinking this behavior might be bug:

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

  10. RyanCavanaugh commented on Nov 16, 2022

    @RyanCavanaugh
    Member

    I guess the precise wording would be that

    The new satisfies operator lets us validate that the type of an expression (evaluated with a contextual type of the asserted type) matches some type, without coercing the resulting type of that expression to the provided type.

    If the operator didn't work this way, then [1, 2] satisfies [number, number] would actually fail, which I think would seem pretty weird.

  11. acutmore commented on Nov 16, 2022

    @acutmore
    Contributor

    I like that wording, nicely put.

  12. fatcerberus commented on Nov 17, 2022

    @fatcerberus

    If the operator didn't work this way, then [1, 2] satisfies [number, number] would actually fail, which I think would seem pretty weird.

    Agreed, with the caveat that this is then squarely in "design limitation" territory - it feels very much like the intent of satisfies is that such an assertion should succeed without changing the type of the expression, and the only reason this is impossible today is because of internal compiler implementation details.

  13. RyanCavanaugh commented on Nov 17, 2022

    @RyanCavanaugh
    Member

    it feels very much like the intent of satisfies is that such an assertion should succeed without changing the type of the expression,

    I guess the question is how you would interpret x => (x + 1) satisfies (n: number) => number. For [1, 2] we can reasonably discuss its narrowest possible type readonly [1, 2], but for a function expression it's sort of meaningless to talk about its narrowest possible type (in the absence of a completely different type inference algorithm).

  14. Zamiell commented on Nov 18, 2022

    @Zamiell
    Contributor

    Here's a slightly different example of satisfies changing the type of an object:

    https://www.typescriptlang.org/play?ts=5.0.0-dev.20221118#code/KYDwDg9gTgLgBDAnmYcASBDAzgQQDZ4CiAdgK4C2A0sIlgDwAqcoMwxAJlnFjFAJbEA5nAA+cMuQBGwKAD44AXjgBvAFBw4UYBnYRieRHADaAaxpwBcBgF0AXHFLETxCAHdiAblUBfL6rYUcACyiCSBahoaAGoYeKTAAIwANOqRMXHAAEwpkXDp8QDMKd6qqgD0AFQVcOrVOHDkGGBgAsIQAGbBoRIIEHAY4hTSUAikYHjAAHS1NXDVDAAWfFwL2OJ9MlDQWNNzZf7g0PAAxno8wQCaAPqEAHIAqkFXDADyVw9BAEKEAErP9wAFAAyhCuCXsEU02l0+kMpnMlhCYXIdjgP2hegMQSadAkwySxhA9jxMgJiGJQxk1lkXm8ihUqSMSIkk3yiVRxGArmCTQAFEZUhojAAGAlGAC0yTgBWs1hycGsAEoUozmRRWbF4pkOVyeWB+YLjKLjJKCTK5YajFKrUVpbL5UqVUK1eQNRkZcTddj9QLciKxQA2AmS+2W61BuAAdlDfuyxgAHAToxaNI6fP0uKdiDw-JUKjN6o1mq04B0usjev1BlIZKNxlM4LzFqhQBhjvAsBhyKgYEshBm4NJ2tAporajNFss4KsuJttoPgMcMKQsKhXKgdOxgOwqwAiTswZbtPjALC7uBZnhQDACGCTOAAdVW8EEfAAbqeAPy1fagSCwC8zngIJrjuR5njeD5vj+BhARBK5MghVItB0TE4TMQxEW6ChUXRVDYW9XFKSgMUiWrfE4HJciqRpdMlEhJlsNdNkEh1blvQNP1jQlKVzQdZVSmdJi3S1Ni9U4yJ-RNXiY0k60EltPjUjTVVhLZD1xC9PlfUk7iIxDFM5MDJNZKFOMjETKNZLTOk1kvDsMEPLBj1PdBsHwIgJGoWg6BdWigA

    In this playground, we have two identical objects. But with the second object, we add a satisfies constraint, which causes it to throw a compiler error. But the compiler error has nothing to do with the constraint itself (which should always pass) - it has to do with the satisfies operator actually mutating the type of the object itself (which causes the as const part to not work properly, I guess).

    I think that this is just the same widening behavior that the OP is describing, but I'm not 100% sure. (I can open a new issue if needed.)

    Like others in the thread, I would naively expect the satisfies operator to be a "verify my thing" operation, not a "subtly modify the thing I just created while also verifying it" operation, so some more documentation surrounding this would be appreciated. (Right now, I can't even find any documentation for satisfies on the official handbook!)

  15. locked as resolved and limited conversation to collaborators on Oct 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Not a DefectThis behavior is one of several equally-correct options

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions