Skip to content

not tracking potential state change in object literal with literal type property #61761

Closed as not planned
@manuelbarzi

Description

@manuelbarzi

🔎 Search Terms

TS2367, no overlap, object property mutation, method call, type narrowing, union type

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about TS2367 and common bugs.

⏯ Playground Link

https://www.typescriptlang.org/play/?ts=5.9.0-dev.20250525#code/PQKgUASgogigqgSWgWSgOQCoGUwFoAEAzgC4CGxApgFz4AeAngDT4D2ATgJYUB2ZxHLbvgAUaRlEZZGAdQCUefACMKAC1IA3AWxoAzdgHdSbACaNFpAMYBrQycacA5iuKMANhR3El9IpQAOhGAgwGBgxPR+FPgQLIosXgC8+ADeYPjpdDTcAK4AtspsaRn0WXkFRensXLzkAtw0AERoDfgAPvgNUC3tDVjdHdINoRn4emy2xsKyNOosHMYVSpY2RpPT+LPzi47OUzNzCyPunnsbB2AAvqEWgiT4bLHxNDFxiSmLtDQADIyLJfg-RZVHh8OqNLq-RZjCZTd4jEaEfQcYgWFQiYgqDiEAB0wJq-EEsjh8JJFlIhCinQaVEWJJJGKx2No+CSDJxzIA1PgAIxfWl0kaKNgUUhWfmk8mUvo0gUCtnYnyszE4nxc3ni2VCkVi2UZMkUgbUjVy5VMln4eXMgjq3UkrWi416yUdZoy20jeWKi2mnzWvnuwXCh0Cq4jC6QwXLGFE1ICxHI1Ho0140GE4m6-WUrpugPexnMpX5-B+x12oM622ZjrS0vwz3m+sl3Ppe0VjPOhqDHMBy0N02cnn+5ut2tVppG5t5lV9xmqwe1wPajWhjLh4YZY7EWGxunxlFo4TylO1NM79sGqnd91Hzggk9CJLjhcZEfusc1yc36qph8dLrPlty1HDsuwAqdcVvfE6nNXohknV9Kw7V0wK-O8CV-Ts4OHICQ0WNdtg4JwtxjDU90TQ9k0gn8SLfDtsxQyjv3vGC+jAhDzylCdm1QqDBBgwY2JwxCL1Az9GLQ6DH2aQSl1oi9kLExlj3QmD-3goSSRXdIriuMAbm4QgWHcbEOG4PRhAaDAoCwDB7keYgGnkPTbiMigTLMlgLIAYQAQSwKA7NeQh8FyFh1AoYLoVWVhuCiEgKD8RzQjPQL4jNJIh3SB5XgVc1MtS4gIKYlTH3-bZ7OxKKTCmdd0n0wzjPJCk2C3bK0oLBIkl5ZgGjarxmSxQckpGerXOxJqKBa4Q+tyzqMp6vr8B8QavmGvUXMawhmtairlMkzq-waBb7NYKjmMGrp5F00bjNM8yGl8-yCuC0LwuC8xrAmGK4v8JKwBSmaCwBcqcq9fKZr2vjSqGEG0o+lZqqckaNrciapsBlkDtwbqOkWgbgux1b5GRgyxrRnbQcx+bcZO5bgqJxYbtRrbJoptLIYfA6qWO15TuK6CLqS66Ufc+7HoCvrguIbI2AM-BN2+3wEr+gGKqB8GKrB2HCo51SYZGGbNxqxmRfJ6a1apgEefiOh8BWta6tNln0c1y2fhp3m6YBB38CZ8bnbZnWzpKrnmmtrxdcG5oruuEW7s8h6-Il+ypZluWdgj2KlcSpzVZy9Xtdm4GDd24PJMO2qCuxDPjZJhrme28387d8Pbft4n1tJzbG5mxUDvd3rabt+mfb9s2IbLqGub6VvI+CvoY+cru3Pj7yk+ei00+CjPFfivxRgeXJ8DQC0WHwS7ksLguS8pjLC91qT9YyGaa6Rzv6-9nuLbmq2PZt-HvYd0dsvT+rMm5pT7tTQenth6AJNiA8epd+ZT2hrPSeQhBaLz9qvROT1FqvQiqfW2vIvj4FINwYwS18AAFZSHkMoTwYwwUkQYj5hJPi0dL433anlQuWtuFB2QRhMqIwxgiHcF4DgeUADcdt8AAB5ByyI4ByDkQD4QzSqmsSuhsPDEUrmI4QEi5EZWUQomhyjVHqIEdieG0ZK5jwDuAwqHUupfAHnjWBJDR5Oy-rfA6tDW5e1oT4hBTiJ5CMtuONBkSo5CyAA

💻 Code

/*
REQUIREMENTS
- state: xy, orientation (N,E,S,W)
- behavior: forward,backward,right,left by steps
*/

type Robot = {
    x: number
    y: number
    orientation: "N" | "E" | "S" | "W"

    forward(): void
    backward(): void
    right(): void
    left(): void
}

const robot: Robot = {
    x: 0,
    y: 0,
    orientation: "E",

    forward() {
        switch (this.orientation) {
            case "E":
                this.x = this.x + 10
                break
            case "S":
                this.y = this.y + 10
                break
            case "W":
                this.x = this.x - 10
                break
            case "N":
                this.y = this.y - 10
                break
        }
    },

    backward() {
        switch (this.orientation) {
            case "E":
                this.x = this.x - 10
                break
            case "S":
                this.y = this.y - 10
                break
            case "W":
                this.x = this.x + 10
                break
            case "N":
                this.y = this.y + 10
                break
        }
    },

    left() {
        switch (this.orientation) {
            case "E":
                this.orientation = "N"
                break
            case "S":
                this.orientation = "E"
                break
            case "W":
                this.orientation = "S"
                break
            case "N":
                this.orientation = "W"
                break
        }
    },

    right() {
        switch (this.orientation) {
            case "E":
                this.orientation = "S"
                break
            case "S":
                this.orientation = "W"
                break
            case "W":
                this.orientation = "N"
                break
            case "N":
                this.orientation = "E"
                break
        }
    }
}

console.info("TEST robot")

console.info("CASE robots moves forward one step")

{
    robot.x = 0
    robot.y = 0
    robot.orientation = "E"

    robot.forward()

    console.assert(robot.x === 10, "robot x is 10")
    console.assert(robot.y === 0, "robot y is 0")
    console.assert(robot.orientation === "E", "robot orientation is E")
}

console.info("CASE robots moves backward one step")

{
    robot.x = 0
    robot.y = 0
    robot.orientation = "E"

    robot.backward()

    console.assert(robot.x === -10, "robot x is -10")
    console.assert(robot.y === 0, "robot y is 0")
    console.assert(robot.orientation === "E", "robot orientation is E")
}

console.info("CASE robots turns left one step")

{
    robot.x = 0
    robot.y = 0
    robot.orientation = "E"

    robot.left()

    console.assert(robot.x === 0, "robot x is 0")
    console.assert(robot.y === 0, "robot y is 0")
    console.assert(robot.orientation === "N", "robot orientation is N")
}

console.info("CASE robots turns right one step")

{
    robot.x = 0
    robot.y = 0
    robot.orientation = "E"

    robot.right()

    console.assert(robot.x === 0, "robot x is 0")
    console.assert(robot.y === 0, "robot y is 0")
    console.assert(robot.orientation === "S", "robot orientation is S")
}

console.info("CASE robots turns right one step from N to E")

{
    robot.x = 0
    robot.y = 0
    robot.orientation = "N"

    robot.right()

    console.assert(robot.x === 0, "robot x is 0")
    console.assert(robot.y === 0, "robot y is 0")
    console.assert(robot.orientation === "E", "robot orientation is E")
}

console.info("CASE robot moves to x 100 and y 50 and ends with orientation N")

{
    robot.x = 0
    robot.y = 0
    robot.orientation = "E"

    for (let i = 0; i < 10; i++)
        robot.forward()

    robot.left()

    for (let i = 0; i < 5; i++)
        robot.backward()

    console.assert(robot.x === 100, "robot x is 100")
    console.assert(robot.y === 50, "robot y is 50")
    console.assert(robot.orientation === "N", "robot orientation is N")
}

🙁 Actual behavior

TypeScript throws TS2367: This comparison appears to be unintentional because the types '"E"' and '"N"' have no overlap. after the robot.left() call. This suggests TypeScript incorrectly narrows robot.orientation to the literal type "E" after the assignment robot.orientation = "E", ignoring the mutation in left().

Same applies for the robot.right() method.

🙂 Expected behavior

TypeScript should recognize that robot.orientation can be modified by the left() method to any value in the union type "E" | "S" | "W" | "N". The comparison robot.orientation === "N" should not trigger a TS2367 error, as the left() method can set robot.orientation to "N" when starting from "E".

Same applies for the right() method.

Additional information about the issue

This issue resembles #55215 (), where TypeScript fails to account for class field mutations in methods, and #51586 (), where mutations in async contexts (e.g., setTimeout) are not tracked. In my case, the mutation occurs in a method of a standalone object, not a class or async context, but the core issue seems similar: TypeScript does not track property mutations in method calls.

The error prevents valid test cases from compiling, even though they work correctly at runtime.
A workaround is to use a type assertion (e.g., (robot.orientation as "E" | "S" | "W" | "N") === "N") or a helper function, but this is cumbersome and shouldn’t be necessary for correct code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    DuplicateAn existing issue was already created

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions