Nexus Instance - Object Oriented Library for Roblox

2023 Edit: This post has not been maintained in a long time and is based on V.1.X.X of Nexus Instance with most probably working on V.2.X.X. V.3.X.X is incompatible with most or all of the examples below.

Nexus Instance is an object oriented (OO) library designed for Roblox. The goal to provide some of the functionality of Roblox’s Instance object, as well as providing the context of “super” classes when extending a class.

Why Object Oriented?
This thread is meant to be a tutorial about using Nexus Instance; not Object Oriented programming in general. There are many different articles, books, and videos explaining why Object Oriented is “better” compared to functional programming. As a TL;DR of the benefits, I will pull the 4 main features of object oriented design from the answer key from the second assignment from one of my several object oriented university courses:

  • Identity - Classes are templates. Objects are instances of those templates. Every object is uniquely identifiable.
  • Encapsulation - State and behavior are grouped together inside an object. The object protects and controls access to them using access modifiers.
  • Inheritance - One class may establish a parent-child relationship with another. The child class inherits the accessible state and behavior of its parent.
  • Polymorphism - Any instance of a child class may be used where an instance of its parent class is expected. A reference may refer to an instance of any compatible type. What appears to be one method at compile time may be one of many different implementations at runtime (overriding).

Of course, object oriented is not a “silver bullet” that will make your code instantly better. You need to properly design for it, as well as practice. I wouldn’t make your first project with object oriented a large-scope project spanning months.

Why Use Any Library?
The main reason object oriented libraries get made for Lua is because the built-in method (metatables) can be very confusing for new users. Here is an example class with a constructor using Lua’s metamethods:

local TestClass = {}

--[[
Constructor for TestClass.
--]]
function TestClass.new(Value)
    --Create the base object.
    local Object = {Value = Value}
    setmetatable(Object,TestClass)
    TestClass.__index = TestClass

    --Return the object.
    return Object
end

--[[
Returns the value of the class.
--]]
function TestClass:GetValue()
    return self.Value
end

--Create an instance of the class.
local TestObject = TestClass.new(2)
print(TestObject:GetValue()) --2

The metatables used above are something that can be generalized so it doesn’t have to be repeated. Inheritance is also a bit complicated because you need to make several metatable calls, and you still won’t have a good way to reference a super function. It can be done, but makes refactoring difficult if you change the inheritance.

Why Nexus Instance?
One thing I didn’t know going into the project is that there is a lot of different object oriented libraries that exist for Lua, and the syntax is different between them. Syntax mostly comes down to which you prefer. The functionality that is implemented into Nexus Instance includes:

  • Easy extending of classes.
  • Proper hierarchy with a “super” property.
  • Class names and a built in “IsA” function.
  • Property change signalling. (Note: The current implementation uses Roblox’s BindableEvent object, so it is Roblox specific).

Using Nexus Instance
Most of this tutorial will mirror the “Why Nexus Instance” on the GitHub pages documentation. In this case, I will be using shapes as an example.

Creating a Base Class
Most objects in Roblox inherit from a common class (Instance). Nexus Instance is a same, except NexusObject is the common class. To create an empty class, NexusObject::Extend needs to be called on the base class. It is also recommended to set the class name using NexusObject::SetClassName. As an example, here is how you would create a “Rectangle” class:

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusObject = require(NexusObjectFolder:WaitForChild("NexusObject"))

--Extend NexusObject for a Rectangle class.
local Rectangle = NexusObject:Extend()
Rectangle:SetClassName("Rectangle")

--Create the object.
local RectangleObject = Rectangle.new()

--Return the class (assuming it is a ModuleScript).
return Rectangle

Overriding the Constructor
Some classes will require some type of arguments given as part of the constructor to create the object. An example of this is Instance.new requiring the class name to create. For this, you need to override the __new in the class. To simplify setting parameters, the function gets called on the instantiated object so a self exists. Here is the same example with “Width” and “Height” parameters:

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusObject = require(NexusObjectFolder:WaitForChild("NexusObject"))

--Extend NexusObject for a Rectangle class.
local Rectangle = NexusObject:Extend()
Rectangle:SetClassName("Rectangle")

--The constructor of the rectangle.
--The following is functionally identical to Rectangle.__new = function(self,...)
function Rectangle:__new(Width,Height)
	self.Width = Width
	self.Height = Height
end

--Create the object.
local RectangleObject = Rectangle.new(2,4)
print(RectangleObject.Width) --2
print(RectangleObject.Height) --4

Initializing the Super Class
In most cases, you will want to initialize the super class to initialize the behavior. If the constructor isn’t overridden, this is done implicitly using the InitializeSuper function. If you do override the constructor, it is recommended (or potentially required) to explicitly call this. This function will call the constructor of the superclass with self.super as self, as well as any parameters. For the Rectangle example, a Square is a reasonable class to extend from it, with only a side length being the constructor.

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusObject = require(NexusObjectFolder:WaitForChild("NexusObject"))

--Extend NexusObject for a Rectangle class.
local Rectangle = NexusObject:Extend()
Rectangle:SetClassName("Rectangle")

--The constructor of the rectangle.
--The following is functionally identical to Rectangle.__new = function(self,...)
function Rectangle:__new(Width,Height)
	self.Width = Width
	self.Height = Height
end

--Extend Rectangle for a Square class.
local Square = Rectangle:Extend()
Square:SetClassName("Square")

--The constructor of the rectangle.
function Square:__new(Length)
	self:InitializeSuper(Length,Length)
end

--Create the Rectangle object.
local RectangleObject = Rectangle.new(2,4)
print(RectangleObject.Width) --2
print(RectangleObject.Height) --4

--Create the Square object.
local SquareObject = Square.new(2,2)
print(SquareObject.Width) --2
print(SquareObject.Height) --2
print(SquareObject.ClassName) --"Square"
print(SquareObject:IsA("Rectangle")) --true

Adding Functions
A key feature of object oriented design is being able to abstract functionality behind certain methods. It is important to be able to either inherit the behavior or to override the behavior while keeping the same name (Polymorphism). In the Rectangle example, Rectangle::GetArea can be added, and the Square will inherit the method.

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusObject = require(NexusObjectFolder:WaitForChild("NexusObject"))

--Extend NexusObject for a Rectangle class.
local Rectangle = NexusObject:Extend()
Rectangle:SetClassName("Rectangle")

--The constructor of the rectangle.
--The following is functionally identical to Rectangle.__new = function(self,...)
function Rectangle:__new(Width,Height)
	self.Width = Width
	self.Height = Height
end

--Returns the area of the rectangle.
function Rectangle:GetArea()
	return self.Width * self.Height
end

--Extend Rectangle for a Square class.
local Square = Rectangle:Extend()
Square:SetClassName("Square")

--The constructor of the rectangle.
function Square:__new(Length)
	self:InitializeSuper(Length,Length)
end

--Create the Rectangle object.
local RectangleObject = Rectangle.new(2,4)
print(RectangleObject:GetArea()) --8

--Create the Square object.
local SquareObject = Square.new(2,2)
print(SquareObject:GetArea()) --4

Changed Event
A built in extension of NexusObject is NexusInstance. The primary purpose of NexusInstance is to add a Changed event signal for when an property gets changed. This does add some overhead to run the event, but it should be insignificant. Going back to the Rectangle and removing the Square, here is an example with the GetPropertyChangedSignal function and Changed event.

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusInstance = require(NexusObjectFolder:WaitForChild("NexusInstance"))

--Extend NexusInstance for a Rectangle class.
local Rectangle = NexusInstance:Extend()
Rectangle:SetClassName("Rectangle")

--The constructor of the rectangle.
--The following is functionally identical to Rectangle.__new = function(self,...)
function Rectangle:__new(Width,Height)
	self:InitializeSuper() --This is REQUIRED or signaling won't be set up.
	self.Width = Width
	self.Height = Height
end

--Create the Rectangle object.
local RectangleObject = Rectangle.new(2,4)
RectangleObject.Changed:Connect(function(Property)
	print(Property..": "..tostring(RectangleObject[Property])) --"Width: 4"
end)
RectangleObject:GetPropertyChangedSignal("Width"):Connect(function()
	print("Width: "..tostring(RectangleObject.Width)) --"Width: 4"
end)

RectangleObject.Width = 4

Locking Properties
If you need a property to be immutable (read-only), the function NexusInstance::LockProperty can be used to make a property immutable. An example where this may be useful is any of Roblox’s datatypes, where they need to be re-instantiated rather than changing a component of the userdata. For the Rectangles example, the Width and Height may need to be immutable for performance reasons or how they are used by other parts of the software.

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusInstance = require(NexusObjectFolder:WaitForChild("NexusInstance"))

--Extend NexusInstance for a Rectangle class.
local Rectangle = NexusInstance:Extend()
Rectangle:SetClassName("Rectangle")

--The constructor of the rectangle.
--The following is functionally identical to Rectangle.__new = function(self,...)
function Rectangle:__new(Width,Height)
	self:InitializeSuper() --This is REQUIRED or signaling won't be set up.
	self.Width = Width
	self.Height = Height
	
	--Lock the properties.
	self:LockProperty("Width")
	self:LockProperty("Height")
end

--Create the Rectangle object.
local RectangleObject = Rectangle.new(2,4)
RectangleObject.Width = 4 --"Width is read-only."

Changes
Version V1.1.0 (Interfaces, Property Validation, Metamethod Passthrough)

47 Likes

Excellent work! Creating of objects in Lua is pretty bulky in my opinion, this library is very helpful.

1 Like

Interesting library, I always hated creating classes in Lua simply because it was annoying writing the metatables out.

I’m also curious why you have a :SetClassName method. Wouldn’t it be a bit cleaner just to have NexusObject:Extend(string className)?

2 Likes

It is something I was considering but decided not to do in case I was going to add extra behavior when setting up the class.

If you wanted some future proofing in case you wanted other behavior you could make it take a table which should help mitigate that issue.

Something you could consider is creating a wrapper, or extending NexusObject/NexusInstance and overriding the Extend method.

local Sources = game:GetService("ReplicatedStorage"):WaitForChild("Sources")
local NexusObjectFolder = Sources:WaitForChild("NexusObject")
local NexusInstance = require(NexusObjectFolder:WaitForChild("NexusInstance"))

--Extend NexusInstance.
local NexusInstanceHelper = NexusInstance:Extend()

--Override the extend function.
function NexusInstanceHelper:Extend(ClassName)
	local ExtendedClass = self.super:Extend()
	ExtendedClass:SetClassName(ClassName)
	return ExtendedClass
end
	
--Test an extended class.
local TestClass = NexusInstanceHelper:Extend("TestClass")
local TestObject = TestClass.new()
print(TestObject.ClassName) --"TestClass"
1 Like

I suggest you look at roblox-ts
https://roblox-ts.github.io/docs/quick-start
TypeScript is a truly beautiful OOP language which, more importantly, has interfaces. The algebraic typing system is very powerful and it has type definitions written for Roblox. Plus there’s hundreds of platform agnostic libraries written for it.

If you combine it with Rojo then your TypeScript can be automatically compiled and pulled into Roblox Studio any time you make a change. It’s a really solid workflow.

The Lua it compiles to is very readable as well, which is a plus.

2 Likes

TypeScript is something far beyond the scope of what Nexus Instance was meant to achieve. The goal was to keep everything in-platform and require majoring refactoring to adapt to, mainly for my projects.
Roblox-ts also has a higher barrier to entry since you need to set up both that and Rojo, then practice the syntax. As someone who doesn’t require the features it gives, but does want event signaling, Nexus Instance works for me.

1 Like

Interesting project for sure! I recently got into playing around with OO Lua constructs. Sadly since I am still not terribly familiar with the best ways to implement many parts of it my system lacks perhaps the most important part of true OO philosophy, inheritance. I specifically sacrificed it when I was looking at ways to accomplish more robust encapsulation than I had.

A few things I’d love to see added to your implementation, input capturing and validation. Currently you allow for locking properties so that they are read only which is great. Sometimes though you don’t want to prevent changing of a property you just want to only allow specific inputs and cast when possible.

Sticking with your examples, if we have a rectangle we want to be able to set it’s Width and Height only to numbers, and we always want them positive. When “Rectangle.Width = -2” is called, we should be capturing it for processing first, and validate it against being able to cast “-2” as a number (which it already is so no problem there) and then set Width to math.abs(input). This ensures our object doesn’t break by someone saying Rectangle.Width = “Foo”, and that we never return a negative area.

Anyways, love what you’ve got going so far and look forward to following you expanding it!

1 Like

This is probably something I can implement. It would be a registering function (ex: NexusInstance::AddPropertyValidator), but I am not sure how I would implement it from there. Ideally, it would be a InputValidator class with a few built-in ones for the basic cases (ex: must be a number, must be a UDim2.new(), can’t be nil). It is something I may address later since it isn’t a priority.

1 Like

This looks really helpful! Thanks for making and sharing this!

How extensively has this library been tested in production? I’m interested in giving it a go, but I am novice enough with it all to not know if I should try it on my project.

I have tested it in a few projects, although none of them are public yet. Nexus Admin 1.3.0 will be the first Nexus Instance project to be released, which hopefully be by the end of next week.
If you do want to try it, and you have never done object oriented, I would do some sort of small scale project that relies on only a few classes to see if it will work for you, or even just one (ex: a LoadableObject in a loading screen).

1 Like

Thanks! Looking forward to seeing this grow :slight_smile:

After a bit of designing, a bit of program, a lot of unit testing, and a LOT of documentation, V.1.1.0 has been released! Here are the highlights (from the GitHub pages):

Interfaces (Edit: Removed in Nexus Instance V.2.0.0)
Interfaces exist to enforce the implementation of behavior. Interfaces can also implement behavior, but this is not recommended because interfaces are meant to enforce the implementation of a behavior without implementing it.

Example:

local NexusObject = game:GetService("ReplicatedStorage"):WaitForChild("NexusObject")
local NexusInstance = require(NexusObject:WaitForChild("NexusInstance"))
local NexusInterface = require(NexusObject:WaitForChild("NexusInterface"))
local TestClass = NexusInstance:Extend()
TestClass:SetClassName("TestClass")

--Create an interface.
local TestInterface = NexusInterface:Extend()
TestInterface:SetClassName("TestInterface")
TestInterface:MustImplement("Test")
TestClass:Implements(TestInterface)

--[[
Implement the test function. Not doing
this will result in an error for not
implementing the function test.
--]]
function TestClass:Test()
    --Implementation
end


--Create an instance of the class.
local TestObject = TestClass.new()
print(TestObject:IsA("TestClass")) --true
print(TestObject:IsA("TestInterface")) --true

Property Validation
Property changes can be validated before being set. This allows for modifying of inputs to correct values, or throwing errors if the new property is invalid.

Example:

local NexusObject = game:GetService("ReplicatedStorage"):WaitForChild("NexusObject")
local NexusInstance = require(NexusObject:WaitForChild("NexusInstance"))
local TypePropertyValidator = require(NexusObject:WaitForChild("PropertyValidator"):WaitForChild("NexusInstance"))
local TestClass = NexusInstance:Extend()

--Create and add a property validator.
local TestObject = TestClass.new()
local Valdiator = TypePropertyValidator.new("CFrame")
TestObject:AddPropertyValidator("Location",Validator)

--Set the property.
TestObject.Location = CFrame.new()
TestObject.Location = "Test" --Throws an error

Metamethods
Most of the metamethods can be implemented into classes directly. This is mostly for __tostring , but can be used for any other Lua metamethod that isn’t __index or __newindex .
Example:

local NexusObject = game:GetServic exusObject:WaitForChild("NexusInstance"))
local TestClass = NexusInstance:Extend()

--[[
Constructor for TestClass.
--]]
function TestClass:__new(Value)
    self:InitializeSuper()

    --Set the value.
    self.Value = Value
    self:LockProperty("Value")
end

--[[
Returns the object as a string.
--]]
function TestClass:__tostring()
    return "Test: "..self.Value
end

--Create an instance of the class.
local TestObject = TestClass.new(2)
print(tostring(TestObject)) --"Test: 2"
2 Likes

I may not be the norm, but I actually like Roblox’s default class creation method via metatables.

That being said, it is utterly terrible when it comes to the fact that you might want to extend/implement classes. I don’t typically encounter the need to extend/implement classes in my day-to-day coding on Roblox, at least not in such a way that it’s ever really been hard to code them.

That changed when I needed to create a class/component based user interface system, I have found Nexus Instance to be extremely useful and powerful, since it makes my specific component code very simple and easy to work with, especially with the inclusion of interfaces.

I’ll probably just use this for my user interface code, but who knows that might change in the future.

This is amazingly nice and simple. Thanks!

I am in the process of doing a rewrite of Nexus Instance. It is much faster and much simpler compared to the outgoing V.1.4.1, but it includes a lot of breaking changes. The highlights include:

  • The concept of interfaces is being removed. They were too complex for what they offered. No current system I support uses them.
  • LuaEvent has been removed and RobloxEvent has been renamed to NexusEvent. NexusEventCreator has been deprecated, but is still included.
  • self.super.(index) will attempt to return the next unique value. For example, if you had Class1, Class2, and Class3 with a function named Method defined Class1 and Class2 but not Class3 and called Class3.new():Method(), it would call Method on Class2 twice and then Class1 in V.1.4.1, but will only do Class2 once in V.2.0.0.
  • The __index and __newindex methods in NexusObject are now tables, which is where the performance improvements come from. NexusInstance is unchanged.
  • __createindexmethod no longer used with NexusObject. NexusInstance is unchanged.
  • Metamethod passthrough is still supported, but not dynamically.

To clarify above, NexusObject is the lighter weight base class while NexusInstance adds extra features like the Changed event. Memory usage is down for both, but I don’t have good comparisons. For time comparisons:

  • Creating NexusObject instances is roughly 90% faster.
  • Getting properties from NexusObject instances is roughly 15% faster.
  • Setting properties from NexusObject instances is roughly 15% faster.
  • Creating NexusInstance instances is roughly 75% faster.
  • Setting properties from NexusInstance instances is roughly 10% faster.

It will be a few days until it is released to perform tests on my existing projects. If anyone relies on anything being removed, either be careful upgrading or do not upgrade.

6 Likes

Any chance we could get wally/npm support?

This is way off of the scope of the project, but I think it would be pretty cool if someone made a lua interpreter which then could have additional keywords such as Public, Private, Static, etc., which then get compiled to run lua during run-time. It would be a cool way for programmers who only know lua to begin using classes and OOP. I don’t know how long such an undertaking would amount to, but it’s a cool concept to think about.

On topic now, while I like the idea of using existing functionality in lua to implement classes, I feel the use of the methods make it less clear, what my code does for other users. I might give it a go on a solo project at one point though. Eventually I’m hoping there will be proper lua syntax for this, but I don’t think it’s likely.

I don’t intend to start doing this since I don’t use these.

The concept of Public/Private was something I actually did take a brief look at, and I couldn’t figure out a way of doing this at runtime without relying on debug.traceback somehow. It wasn’t worth it for me and would have caused a lot of complications with V2 to the point where V2 probably would not exist.

¯\_(ツ)_/¯
If you don’t feel it is a good idea, then stay with functional programming. Blindly using object-oriented everywhere can be problemtic.

3 Likes

Make public and private a function if you want a syntax hack:

class { -- syntax hack, tables passed into functions don't need curlies, curried to accept a value
    public "open" ^ value -- strings passed into functions don't need curlies, have it return something with the __pow metamethod
    private "hidden" ^ value
}

The class function could be responsible for handling this public private stuff. The easiest way to achieve this is with multiple self objects:

  1. The private view on the raw data
  2. The public view on the private data (implement with __index into private view)

Have the constructor function wrapped by the class. In the wrapper, create the public view based on the private review returned by the constructor, and return the public view.

In the public view’s __index metamethod, determine if the return value is a function, if it is then wrap it with the following

function callMethodPrivate(method, privateSelf)
  return function (publicSelf, ...)
      --private self & method are captured in a closure, not part of the public view
      return method(privateSelf, ...)
  end
end

additionally, filter out private values.

That said, do I think this is a good idea? Probably not.