I was just playing around with the easiest and cleanest way of adding onto (or even modifying) Roblox Instances while keeping the functionality the same. It relies on nothing but metatables and “tricking” intellisense with forced generic types.
This module is pretty simple and lightweight, so it doesn’t support things like multiple class extensions and proxies, so feel free to modify this module as you see fit.
Class.lua
if allowNewProperties == true then
return nil
else
error(`{index} is not a valid member of {instance.ClassName} "{instance:GetFullName()}"`, 0)
end
Wally
Class = "uiscript/class@0.1.1"
# Note: You will need something like wally-package-types to
# automatically export the types. Otherwise, import it manually.
Version 0.1.1 - nil index hotfix
- Before: When trying to access a nil-able index (ex:
if class.Data == nil
), it would throw an error similar to accessing an invalid Instance property.
After: IfallowNewProperties
is set to true, it will returnnil
instead of throwing an error.
Version 0.1.0 - Official release
- Constructor changed from
Class()
toClass.new()
- Added
Class.clone()
as an alternative totable.clone()
- Added
Class.Stored
to access all classes created directly - Added Wally support
- Removed __class from metatable (redundant index I forgot to remove)
Usage & Example
Spleef Class
--!strict
local Players = game:GetService("Players")
local ServerStorage = game:GetService("ServerStorage")
local Class = require(ServerStorage.Class)
local SpleefClass = require(script.Parent.Classes.SpleefClass)
--[=[
Usage:
Class.new(instance: Instance, class: Class?, allowNewProperties: boolean?, ignoreClassWarning: boolean?)
By default, allowNewProperties will be false.
- true:
If the Instance itself does not have that property, it will create a new index inside
the class table with it's new value. This is useful if you have indexes that can be nil
or is nil before it is created and you do not want to use rawset(class, index, value).
- false:
If the Instance itself does not have that property, it will throw an error similar to accessing
a missing property. This can be problematic if you have indexes that can be nil or is
nil before it is created, meaning you will have to use rawset(class, index, value) instead.
By default, ignoreClassWarning will be true.
- true:
If the class provided does not match the class of the instance, it will not output a warning.
This should only be used if you are cloning a class for multiple Instances.
- false:
If the class provided does not match the class of the instance, it will output a warning.
This is also true if you do not provide a class or if you clone a class. If you are cloning
a class, set this argument to true to silence the warning.
Class.clone(class: Class, deep: boolean?)
By default, deep is set to true.
If deep is set to true, the clone function will perform a deep operation. Otherwise, it is essentially
the same as table.clone().
Classes.Stored (index: Instance, value: Class)
A dictionary of all the classes currently stored.
Does not support intellisense. This should be used if you need to manually remove the attached
instance from memory or want to meet the class condition with ignoreClassWarning set to false.
]=]
local SpleefPart: SpleefClass.SpleefClass = Class.new(Instance.new("Part"), Class.clone(SpleefClass))
SpleefPart.Anchored = true
SpleefPart.Size = Vector3.new(10, 1, 10)
SpleefPart.CFrame = CFrame.new(0, 3, 0)
SpleefPart.Parent = workspace
SpleefPart.Touched:Connect(function(hit: BasePart): ()
if SpleefPart.IsFading == false and SpleefPart.Transparency :: number <= 0 then -- unfortunately non-overridden properties will return the type as "any & type"
local character = Players:GetPlayerFromCharacter(hit.Parent) or Players:GetPlayerFromCharacter((hit.Parent :: Instance).Parent)
if character ~= nil then
SpleefPart:Disappear()
task.wait(3)
SpleefPart:Appear()
end
end
end)
-- This will error since allowNewProperties is set to false
SpleefPart.InvalidProperty = true
-- Somewhere else
local SpleefPart: SpleefClass.SpleefClass? = Class.Stored[instance]
if SpleefPart ~= nil then
-- do stuff
end
--!strict
local ServerStorage = game:GetService("ServerStorage")
local Class = require(ServerStorage.Class)
type Class<instance, class> = Class.Class<instance, class>
local SpleefClass = {
IsFading = false,
}
export type SpleefClass = Class<BasePart, typeof(SpleefClass)>
local function fade(spleefPart: SpleefClass, from: number, to: number, alpha: number): ()
spleefPart.IsFading = true
for i: number = from, to, alpha do
spleefPart.Transparency = i
task.wait()
end
spleefPart.Transparency = to
spleefPart.CanCollide = to < 1
spleefPart.IsFading = false
end
-- You can have full intellisense support by declaring it as a SpleefClass
function SpleefClass.Appear(self: SpleefClass, alpha: number?): ()
fade(self, 1, 0, (alpha or 0.01) * -1)
end
-- You can have partial intellisense support by declaring it as
-- typeof(SpleefClass) & BasePart (lose metatable autocomplete)
function SpleefClass:Disappear(alpha: number?): ()
local spleefPart: typeof(SpleefClass) & BasePart = self
fade(spleefPart, 0, 1, alpha or 0.01)
end
-- You can even override a method
function SpleefClass.IsA(self: SpleefClass, className: string): boolean
if className == "Spleef" then
return true
else
return getmetatable(self).__instance:IsA(className)
end
end
return SpleefClass :: typeof(SpleefClass) & {[any]: any}
Screenshots
1. Does this support properties?
Yes. Read question #2 for details.
2. When do I need to clone the class?
It all depends if the class is going to be used for multiple instances.
-
Say you want to create a class for the
Players
service, with a property calledNumPlayersLoaded
and a method calledKickAllPlayers
, in this case you will not be needing to clone thePlayers
class because it is only going to be used for one service anyways. You can if you want to be safe, but you will need to set theignoreClassWarning
if you are planning on attaching the class again on another script. -
What if you wanted to create a
Part
class with a method calledRandomColor
that will be used for multiple different parts? In this case, regardless if you are using properties or not, the Class module will attach a metatable to the class argument passed, meaning it will override the existing metatable if you are passing the same class pointer if that Instance is not already attached, meaning you will need to clone the class everytime for each part.
TLDR: If you are attaching a class that is only going to be used for one Instance (like a Service), you do not need to. If you are reusing a class for multiple instances, you’ll need to clone the class.
The module itself will not clone any classes attached, and it is very barebones so it is flexible if you want to expand upon anything.
3. How do I clone a class?
To clone a class, you can simply use table.clone
for shallow tables. However, if you have deep tables that you also want or need to clone, you will need to create a custom clone function that will return the type as the table passed.
The Class module has a built in clone function using Class.clone(class)
, or you can use this piece of code to do so:
--!strict
-- "deep" default is true
local function tableClone<T>(t: T, deep: boolean?): T
local clone = {} :: any
for index: any, value: any in t :: any do
if typeof(value) ~= "table" then
clone[index] = value
else
if deep ~= false then
clone[index] = tableClone((t :: any)[index])
else
clone[index] = value
end
end
end
return clone
end
4. Why clone a class when you can do a .new constructor?
The reason why you cannot do something like Class(player, PlayerClass.new()) is because of the fact that Class attaches a metatable to your class, so it will just completely override your other constructed metatable (or will error if you have a __metatable index).
It is essentially the same behavior as your standard OOP (by creating a new table), but the difference is that there is no metatable attached to that class.
It is also the same reason why you’ll need to clone a class if you are planning on using it on multiple Instances.
5. Wouldn't cloning a class cause memory issues by cloning functions?
When you decide to use table.clone() or a custom clone, all you are doing to non-table values is simply this:
clone[key] = value
If you have a function, you’re not actually cloning that function, but simply pointing to that function reference.
local c = {
test = function() end
}
print(c.test) -- function: 0x03aec11d43dea5ec
local tc = table.clone(c)
print(tc.test) -- function: 0x03aec11d43dea5ec
Known Bugs
-
When accessing a non-overridden property, the type will return as
any & <type>
.
Useproperty :: <type>
to silence. -
When constructing methods with
function Class.Method(self: Class, ...)
, sometimes the autofill will not pick up the method when using the:
operator.
To fix this, export the class type in your custom class module and declare it like so:
local class: MyClass.Class = Class.new(Instance, MyClass)