Luau: Assertion incorrectly refines type within function

Reproduction Steps
An assertion can incorrectly refine a value within a function. The following script reproduces the issue:

local q: (()->())? = function()end
assert(q)
q()
local function d()
	q = nil
end
local function f()
	q() -- Incorrectly guarded by assertion.
end
d()
f()

No warnings are reported for the script. However, the script errors because q is not a function at the time f is called.

Removing line 2 causes the expected warning to appear.

Expected Behavior
A warning on line 8 is emitted:

Type Error: (8,2) Value of type '(() -> ())?' could be nil

Actual Behavior
The script causes no warnings to be emitted.

Issue Area: Engine
Issue Type: Other
Impact: Low
Frequency: Constantly
Date First Experienced: 2022-04-24 00:04:00 (+00:00)
Date Last Experienced: 2022-04-24 00:04:00 (+00:00)

4 Likes

I guess it’s because of how typechecking evaluates the code:

Because assert() errors when the first parameter is nil or false, and the error will stop the code, the typechecker infers that the rest of the code simply won’t run in case of a nil function, and thus no checks are needed

If you wrap assert in a pcall so that the error won’t stop the code, the warning highlights returns:
image

So probably just an oversight on your end :upside_down_face:

1 Like

It isn’t. Let me try a different example. Here is the same script, but as a module:

local q: (()->())? = function()end
assert(q)
q()
local module = {}
function module.d()
	q = nil
end
function module.f()
	q() -- Incorrectly guarded by assertion.
end
return module

Now a script comes in and calls the functions as before:

local module = require(script.ModuleScript)
module.d()
module.f()

Calling module.f results in an error, as before. The ModuleScript itself continues to report no warnings, as before.

The point is that I need to check whether q exists before calling it within f, because the value of q can change from the time f is initialized to the time f is called. The problem is that linter doesn’t appear to know this.

1 Like

You’re right that the linter doesn’t know because it’s designed to review relatively static code. Currently, in the way your code is structured, at no point in time does it expect the function to suddenly be nil during execution, only prior to it since assert is only called once and that’s right after declaration; otherwise, the linter may as well literally execute the entirety of your code in runtime after each edit to review the types, which is both inefficient and cumbersome.

The way your modulescript is structured is also contributing to the issue; modulescripts only execute once upon require() and then its contents are stored and returned. Therefore, the assert() statement is only ran once, and that’s when it expects the type of q to be static and non-changing, and is explicit.

To achieve what you want, the assert function should be ran each time the script attempts to call q, not just once:

function module.f()
	assert(q)
	q()
end
1 Like

Yes. I know. What I’m saying is that the type checker thinks that value q of type (()->())? used within closure f is refined by the assertion outside of the closure to type ()->(), which is incorrect.

As I’ve already said, the ModuleScript should emit the warning Type Error: (9,2) Value of type '(() -> ())?' could be nil, but it incorrectly does not. If it were working correctly, it would emit this warning, and I would know that I need to add a condition to check q within the function. The fact that the code errors while emitting no warnings indicates a problem.

1 Like

The assertion is doing exactly what you’d expect. The typechecker is doing exactly what you’d expect. You’re asserting the value of q right after the declaration of q, what do you think is going to happen? The script will error right up if q is nil, so typechecking isn’t needed below the assertion since it’s going to error and stop nonetheless, which is what the linter thinks you are expecting because of assertion. Otherwise, if q is a function like you wanted, the rest of the script would run fine, hence the lack of warnings. It’s not “incorrectly redefining” the type, it’s just foreshadowing the chain of events that you had instructed it to do. My point is, that you’re not using assert() in the way it’s intended for your specific purpose, which is why the linter isn’t doing what you expected.

If you simply removed that assertion statement at line 2, the linter will give you back the warning. This is what I meant with my previous post about your modulescript being structured incorrectly. The linter is expecting either a possible error from the assertion or a flawless execution, because that’s how you’ve programmed it to react. As far as I can tell, the linter isn’t the problem, it’s how you’re using that assert function.

TL;DR: that assert function is the root of your problems; the problem doesn’t even come from the linter itself.

1 Like

Apparently I’m explaining this poorly. Let me begin from scratch.

Let’s start with a module.

local export = {}
return export

Functions added to export are callable arbitrarily. That is, The type checker cannot make any assumptions about how these functions will be used outside the module.

Next I will define a function that uses a number value in some way.

local export = {}

local function use(value: number)
	math.abs(value) -- Something that requires a number.
end

return export

Next, let’s define a value. This will be internal state that isn’t exposed outside of the module. To keep things simple, I will make its type a number, and initialize the value to 0. The type will also be nullable.

local export = {}

local function use(value: number)
	math.abs(value)
end

local state: number? = 0

return export

Now, if I try to use this value right way, I will get a warning, because the type of state is currently number?.

local export = {}

local function use(value: number)
	math.abs(value)
end

local state: number? = 0
use(state) --> TypeError - Type 'number?' could not be converted to `number`

return export

Even though the value has been initialized with a number, the type checker isn’t smart enough to know that it is currently safe to call. To fix this, I will refine it with an assertion.

local export = {}

local function use(value: number)
	math.abs(value)
end

local state: number? = 0
assert(state)
use(state) -- state is refined to 'number', so it is safe to use.

return export

Note that adding this assertion does not convert state into just a number. It is still of the number? type, so it is still possible to assign nil to it.

local state: number? = 0
assert(state)
state = nil -- No warning.

After this, I will define two exported functions. One will use state, and the other will set state to nil.

local export = {}

local function use(value: number)
	math.abs(value)
end

local state: number? = 0
assert(state)
use(state)

function export.use()
	use(state)
end
function export.unset()
	state = nil
end

return export

There is a flaw with the export.use function: it fails to check that state is not nil before using it. While fixing it is simple, what is interesting is that the type checker hasn’t emitted a warning for this. Why not? It turns out that, if we remove the assertion, the warning appears as it should:

local export = {}

local function use(value: number)
	math.abs(value)
end

local state: number? = 0
-- assert(state)
use(state) --> TypeError - Type 'number?' could not be converted to `number`

function export.use()
	use(state) --> TypeError - Type 'number?' could not be converted to `number`
end
function export.unset()
	state = nil
end

return export

So the assertion, in addition to refining the type of state within the current scope, is also refining it within the scope of the export.use closure. It is not correct for it to do this, because the value of state can change between the time the assertion is made and the time the closure is called. For example, somewhere in another script:

local module = require(ModuleScript)
module.unset() -- Sets state to nil.
module.use() -- Uses state.
--> Error: invalid argument #1 to 'abs' (number expected, got nil)

Having removed the assertion, we now see the warning, and now know what needs to be fixed.

local export = {}

local function use(value: number)
	math.abs(value)
end

local state: number? = 0
assert(state)
use(state)

function export.use()
	if state then
		use(state)
	end
end
function export.unset()
	state = nil
end

return export

While this code is now completely correct, the main problem is still present, which is that the type checker will incorrectly jump into the scope of a closure to refine the type of an upvalue.

Moreover, the code written here is neither practical nor idiomatic, and is designed exclusively to demonstrate this problem.

2 Likes

I seem to understand the problem now; assert filters out the immediate scenario of state being nil, but in doing so it has neglected to check further on the other road and just assumes a static type for state
sorry for wasting much of your time :sweat_smile:

2 Likes

Hi there, thanks for the report. Unfortunately, fixing this issue isn’t currently on the roadmap at the moment, however we will look into fixing it in the future and we filed a ticket for it !

1 Like

We’ve looked into this and we do not plan to solve this particular issue.
While we still aim to improve the type checking capabilities, there are limits to what we can track statically.