Allow properties of classes and tables to be inferred as write-only.
This RFC is a follow-on to supporting read-only properties.
Read-only properties have many obvious use-cases, but write-only properties are more technical.
The reason for wanting write-only properties is that it means that we can infer a most specific type for functions, which we can’t do if we only have read-write and read-only properties.
For example, consider the function
function f(t) t.p = Dog.new() end
The obvious type for this is
f : ({ p: Dog }) -> ()
but this is not the most specific type, since read-write properties
are invariant, We could have inferred f : ({ p: Animal }) -> ()
.
These types are incomparable (neither is a subtype of the other)
and there are uses of f
that fail to typecheck depending which one choose.
If f : ({ p: Dog }) -> ()
then
local x : { p : Animal } = { p = Cat.new() }
f(x) -- Fails to typecheck
If f : ({ p: Animal }) -> ()
then
local x : { p : Dog } = { p = Dog.new() }
f(x) -- Fails to typecheck
The reason for these failures is that neither of these is the most
specific type. It is one which includes that t.p
is written to, and
not read from.
f : ({ set p: Dog }) -> ()
This allows both example uses of f
to typecheck. To see that it is more specific than ({ p: Animal }) -> ()
:
Dog
is a subtype of Animal
{ set p: Dog }
is a supertype of { set p: Animal }
{ set p: Animal }
is a supertype of { p: Animal }
{ set p: Dog }
is a supertype of { set p: Animal }
is a supertype of { p: Animal }
({ set p: Dog }) -> ()
is a subtype of ({ p: Animal }) -> ()
and similarly ({ set p: Dog }) -> ()
is a subtype of ({ p: Dog }) -> ()
.
Local type inference depends on the existence of most specific (and most general) types, so if we want to use it “off the shelf” we will need write-only properties.
There are also some security reasons why properties should be
write-only. If t
is a shared table, and any security domain can
write to t.p
, then it may be possible to use this as a back-channel
if t.p
is readable. If there is a dynamic check that a property is
write-only then we may wish to present a script analysis error if a
user tries reading it.
Add a modifier to table properties indicating that they are write-only.
This proposal is not about syntax, but it will be useful for examples to have some. Write:
set p: T
for a write-only property of type T
.For example:
function f(t)
t.p = 1 + t.q
end
has inferred type:
f: (t: { set p: number, get q: number }) -> ()
indicating that p
is used write-only but q
is used read-only.
There are various points where type inference adds properties to types, we now have to consider how to treat each of these.
When reading a property from a free table, we should add a read-only property if there is no such property already. If there is already a write-only property, we should make it read-write.
When writing a property to a free table, we should add a write-only property if there is no such property already. If there is already a read-only property, we should make it read-write.
When writing a property to an unsealed table, we should add a read-write property if there is no such property already.
When declaring a method in a table or class, we should add a read-only property for the method.
Write-only properties are contravariant:
T
is a subtype of U
then { set p: U }
is a subtype of { set p: T }
.Read-write properties are a subtype of write-only properties:
T
is a subtype of U
then { p: U }
is a subtype of { set p: T }
.Indexers can be marked write-only just like properties. In
particular, this means there are write-only arrays {set T}
, that are
contravariant. These are sometimes useful, for example:
function move(src, tgt)
for i,v in ipairs(src) do
tgt[i] = src[i]
src[i] = nil
end
end
we can give this function the type
move: <a>({a},{set a}) -> ()
and since write-only arrays are contravariant, we can call this with differently-typed arrays:
local dogs : {Dog} = {fido,rover}
local animals : {Animal} = {tweety,sylvester}
move (dogs,animals)
This program does not type-check with read-write arrays.
Classes can also have write-only properties and indexers.
Some Roblox APIs which manipulate callbacks are write-only for security reasons.
Once we have read-only properties and write-only properties, type intersection gives read-write properties with different types.
{ get p: T } & { set p : U }
If we infer such types, we may wish to present them differently, for example TypeScript allows both a getter and a setter.
This is adding to the complexity budget for users, who will be faced with inferred set modifiers on many properties. There is a trade-off here about how to spend the user’s complexity budget: on understanding inferred types with write-only properties, or debugging false positive type errors caused by variance issues).
Just stick with read-only and read-write accesses.