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.