New Type Solver: Local type-casts are overriding the global types of variables

A new problematic behavior in the new type solver has cropped up. For one reason or another, local type casts are now overriding global variable types, which is causing numerous problems in codebases and making type casts very hard to work with.

See the below example:

--!strict

local x:number? = nil

--This should have a type error
x = x * 2 --Type Error: Operator '*' could not be applied to operands of types number? and number

do
	local replacement:unknown = nil
	--Let's say we know the types match at runtime, which is very common, so we attempt to typecast to ensure safe type overlap
	x = replacement::any
end

--This should also have a type error, but
--uh oh! The type for 'x' is now 'any'!
x = x * 2 --OK

Here’s another example, with a table this time:

--!strict

local t:{number?} = {nil}

t[1] = t[1] * 2 --Type Error: Operator '*' could not be applied to operands of types number? and number

do
	t = {nil}::{any}
end

t[1] = t[1] * 2 --OK

As you can see, the original type of number? is being overridden by the typecast of any, which conflicts with expected typecast behavior and makes them very hard to work with.
When overriding values with ones that have been assigned incompatible types (such as unknown) we typically would typecast it with any to make it compatible, but now we have to tediously cast the types as an exact match, which can sometimes result in nested typecasts due to how the solver works. E.g. (x::any)::number?

Expected behavior

The global type variables should remain, even after a potential typecast of an assigned value (which is only meant to correct the type, not change it entirely)

Typecasts should only override the types when they are explicitly running in a branch of code (I believe this could be called type narrowing here?), but this also changes types even when this wouldn’t be applicable.
Adjusting the code examples to be conditional does not affect the type outcome.

Hello, thank you for beta testing the new type solver. Unfortunately the examples given are working as expected: you’re assigning to a variable with any, the new solver keeps track of that type. We may be able to adjust this behavior when assigning with any, but for example it might be desired in a scenario like:

local function foobar(x: string?)
    s = s or "DEFAULT" -- `s` is now `string`
end

Are there other examples that might illustrate how this is affecting you and your work?

Here’s a direct example from a module in a project I’m working on:

local pending_health_changes:{number} = {}
local pending_death:{true?} = {}

if game:GetService("RunService"):IsClient() then
	local STR = game:GetService("SharedTableRegistry")
	pending_health_changes = STR:GetSharedTable("PendingHealth~")::{any}
	pending_death = STR:GetSharedTable("PendingDeath")::{any}
end

local module = {
	pending_health_changes = pending_health_changes,--Type: {any} | {number}
	pending_death = pending_death, --Type: {any} | {true?}
}

This type-changing behavior was not always there, atleast not in this context, and it is causing issues with these exported values, where external scripts using them are having new type warnings such as the following: (this example may be considered a bug within itself, but that’s besides the point)

--The type for pending_health_changes is ({any} | {number})
table.clear(pending_health_changes)
	--[[Type Error: No valid instantiation could be inferred for generic type parameter V.
	It was expected to be at least: unknown | number and at most: number but
	these types are not compatible with one another.
	]]

Spontaneous changes in type because of potential type-casts are the main issue I’m having here, as they are mutating the type where they haven’t previously.
I understand that it’s saying that the type may be either {any} or {number}, but this is deviating from the defined type in a way that other type solvers (and versions of the Luau type solver) I’ve worked with haven’t. I expect typecasts to be temporary overrides, not mutations of the global variable type.

… this example may be considered a bug within itself, but that’s besides the point

Yes I think in that case, we shouldn’t be erroring at all; we have been trying to do a better job at reporting true generic errors but we’re still iterating there.

I expect typecasts to be temporary overrides, not mutations of the global variable type.

The thing that’s mutating the global type is that you’re assigning to a variable. I think it’s a valid ask to handle assignments better here though, as it can be unexpected when you assign with an any type.

I think my current main point is that it just doesn’t sound appetizing to have a variable’s types switch around when the newly assigned value isn’t an exact match.
When a variable is explicitly typed, it’s generally intended to declare that the value of the given variable will overlap or ‘implement’ the assigned type(s), so it’s a bit jarring when the explicit type declaration shifts around even after being assigned a value with an overlapping type. The idea being that if the new value’s type is different enough to warrant a shift in the variable’s type, that new value would already be deemed incompatible.

Here’s a better code example to display what exactly I’m talking about here because I’m bad with words

--!strict

type a = {
	x: number,
	y: string
}

--Values of this type overlap with type `a`, but not vise-versa
type b = {
	x: number,
	y: string,
	z: number
}


--value of type `a`
local va:a = {
	x = 1,
	y = "word",
}

--value of type `b`
local vb:b = {
	x = 3,
	y = "word2",
	z = 4,
}


local function f(t:{a}):{a}
	--The type of this value overlaps with the explicit type (they should be compatible), so idealy the type wouldn't get silently changed here
	t = {vb}
	
	--.. but it does get changed;
	return t--Type Error: Type '{b}' could not be converted into '{a}'; this is because the result of indexing is `b` in the former type and `a` in the latter type, and `b` is not exactly `a`
end

If you still believe that this behavior should stay as-is, then there is nothing more I can argue here.

That example given is probably a bug: it shouldn’t be legal to assign a { b } where a { a } is expected. The type being updated can possibly change in the future but we shouldn’t allow that assignment, there should be two errors.

FWIW: I agree that the semantics here are confusing. I’m not sure we’ll address this exactly in the way you want, but it could be better.