How Roblox LSP will give object-oriented programmers more power in composition-based architectures

I’ve tried my best to leave as much detail as possible for anyone reading this thread. If you have any questions or concerns, feel free to ask! If you think there are aspects of this topic that could be better explained, please let me know as well.

Disclaimer:

For the sake of this tutorial, I will be using the an OOP idiom more relatable to the default one to not confuse anyone reading this, however I urge you to check out this post which details a new idiom that is even more powerful, which I use in all of my productions.

Composition over inheritance:

There are several case-points for why composition is better than inheritance. A simple query on Google will give you all the information you need. You can also search “Composition over inheritance roblox” to see some examples related to Roblox.

What is Roblox LSP

It is a Luau language server with a VSCode extension designed to offer the necessary tools for Roblox development inside of VSCode. Similar to the built in Roblox Studio script editor, it offers things like autocompletion, typechecking, diagnostics, syntax highlighting and much more.

Why Roblox LSP?

Due to the nature of Roblox LSP, it handles the process of type checking a bit differently than Luau. Because of this difference, it supports something I call “non-recursive cyclic dependencies”. People following the composition paradigm can make use this “feature” to import types from “parent” classes. In Roblox studio, trying to do this will result in the type returned by the module being inferred as any which is not useful to the developer. Many languages have “type-only” requires which allow for this exact sort of thing, but the developers of Luau have said this is not something they will include in the near future. Roblox LSP supports a bypass to this lacking feature.

Example of standard way of doing composition in Roblox (Without Roblox LSP)

local door = require(script.door)
local tycoon = {}
tycoon.__index = tycoon

function tycoon.new(owner: Player, model: Model)
    local self = setmetatable({}, tycoon)
    self.owner = owner
    self.model = model
    self.door = door.new(self, self.model:FindFirstChild("Door"))
    return self
end

return tycoon
local door = {}
door.__index = door

function door.new(tycoon, model: Model)
   local self = setmetatable({}, door)
   self.tycoon = tycoon
   self.model = model
   self.open = false
   self.transitioning = false
   return self
end

function door.open(self: door)
    -- open animation plays
    self.open = true
end

function door.close(self: door)
    -- close animation plays
    self.open = false
end

function door.toggle(self: door, playerInteracting: Player)
    if not self.transitioning then
        if playerInteracting == self.tycoon.owner then
            self.transitioning = true
            if self.open then
                door:close()
            else
                door:open()
            end
            self.transitioning = false
        end
    end
end

type door = typeof(door.new(table.unpack(...)))

return door

Following the standard composition idiom, I made a door for the tycoon. It makes use of having reference to the parent class to be able to check the owner and compare it to the person trying to open the door. This is a basic example, but it becomes very useful as you develop more complex systems.

But there is a problem!

When writing this code I have no type inference inside the door component about what self.tycoon is, meaning I have to personally remember the attributes of that object or switch back and fourth between scripts to find out.

image

tycoon is inferred as type any which is not very helpful!

Enter, VSCode with Roblox LSP

Most languages have support for “type only imports/requires”. Roblox does not. I’ve found a way to implement them using Roblox LSP, and still be valid code inside of Roblox. You must use Roblox LSP because of the nature of it’s design. Roblox’s script editor is designed differently and can’t performantly support type inference on a cyclic dependency (even if it is not causing an infinite loop). See The ability to require something only for its types · Issue #452 · Roblox/luau (github.com)

Some of you will read this code I am about to write below and say it is a cyclic dependency or “That won’t run in Roblox”. But in reality, it will. There are two different cyclic dependencies, one recursively requires the other one indefinitely, causing an infinite loop, and then we have what I like to call “non-recursive cyclic dependencies”. Module A only requires module B within the scope of a function (in this case it’s required in tycoon.new). Module B can than safely require Module A without triggering an infinite loop. This makes it valid Luau that will run in studio, but studios script editor will abolish the type inference (yielding “any” as the type) due to the nature of the typechecker as explained in the linked issue above. It will throw a warning saying it’s a cyclic dependency. Roblox LSP won’t throw this warning because it understands it is a “non-recursive cyclic dependency”. It will keep the type when requiring the module unlike Roblox studio. This supports the ability to import types from parent modules which will give developers much better autocomplete.

local tycoon = {}
tycoon.__index = tycoon

function tycoon.new(model: Model)
    local self = setmetatable({}, tycoon)
    self.model = model
    self.door = require(script.door).new(self, model:FindFirstChild("Door"))
    return self
end

type tycoon = typeof(tycoon.new(table.unpack(...)))
export type Type = tycoon

return tycoon
local tycoon = require(script.Parent)
local door = {}
door.__index = door

function door.new(tycoon: tycoon.Type, model: Model)
    local self = setmetatable({}, button)
    self.tycoon = tycoon
    self.model = model
    return self
end

function door.open(self: door)
    -- open animation plays
    self.open = true
end

function door.close(self: door)
    -- close animation plays
    self.open = false
end

function door.toggle(self: door, playerInteracting: Player)
    if not self.transitioning then
        if playerInteracting == self.tycoon.owner then
            self.transitioning = true
            if self.open then
                door:close()
            else
                door:open()
            end
            self.transitioning = false
        end
    end
end

type door = typeof(door.new(table.unpack(...)))

return door

Ahhh, much better. And it runs in Roblox too! You will get a warning in the Roblox script editor, just ignore that. The warning is due to its misidentification of a bad circular dependency. There is nothing wrong with the circular dependency we’ve created in this example.

I’ve found great use following this code architechture and I can confirm it works. The only reason it does not work in Roblox studio is because the type checker will not even attempt to infer the type when it deems there is a cyclic dependency. This comes down to the fundamental architecture of the type checker, which was talked about in the issue I linked earlier. I’ve spoken with the creator and maintainer of Roblox LSP, this “feature” is not going anywhere, and it is safe to use.

5 Likes

Can’t you infer a type onto tycoon for type inference?

How would you access the type in the submodule?

Although in this setup you cannot reliably get the type, you’re able to get the type. Using a different model (such as using inheritance, in which you can get the type, or having event based systems, where you’re able to listen for the connection), can circumvent this issue. This won’t apply to all systems though.

This is just a clarification, the resource itself is useful.