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.
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.