Class | An easy and smart way of extending Instances with full intellisense support


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: If allowNewProperties is set to true, it will return nil instead of throwing an error.
Version 0.1.0 - Official release
  • Constructor changed from Class() to Class.new()
  • Added Class.clone() as an alternative to table.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

image
image


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.

  1. Say you want to create a class for the Players service, with a property called NumPlayersLoaded and a method called KickAllPlayers, in this case you will not be needing to clone the Players 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 the ignoreClassWarning if you are planning on attaching the class again on another script.

  2. What if you wanted to create a Part class with a method called RandomColor 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>.
    Use property :: <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)

9 Likes

I was wondering if this also supports custom properties as well and not just Methods since you didn’t showcase that in the example code

1 Like

Yes it does and has full support as well. You will need to clone the class like I did with the player class and set the ignoreClassWarning to true if you are planning on reusing the Class function to an already existing class.

You just have to make sure you are deep cloning if you have tables inside tables.

Read FAQ.

1 Like

do I have to use table.clone for class parameter?

Now irrelevant response…

Read FAQ.

1 Like

can you give a short explanation why need table clone for Player?

1 Like

Yep. If you noticed in the example I provided, there is a property called DataLoaded.
Assuming you never clone the table, If the first player ticks that “property” to true, all classes that are “created” will have that property tick to true since it doesn’t clone the class internally (direct table reference).

1 Like

I see, thank you!

I will ask a few more questions because I want to make sure that this will work for my use case

I use Test driven development and TestEz to test my code’s functionality

Now it is currently impossible to create a Player Instance, would I be able to do that with your module?

I still wouldn’t be able to create a Player instance but I can create a Configuration (because it’s blank) Instance and fill it up with Properties and Methods that a Player class would

Would this be a possible use case?


Also do would I have the capabilities to overwrite a Method for example would I be able to override ‘IsA’

Technically yeah. This basically just creates a table class on top of the Instance API, and your custom class is no different than the standard OOP approach.

I originally designed this because I wanted to replace the attributes system to my own system that supports objects and tables. I haven’t gotten around doing so but you can replace existing methods and (and even properties) and have intellisense point to that one instead.

Example:
Read Usage & Example

If you want to create a psuedo Player Instance from scratch without relying on Instances, then it’s better if you just make your own class.

In terms of tests, I’ve tested properties, methods, events, and even direct children referencing (which amazingly intellisense still supports), but if you encounter any issues with anything else, I would be more than happy to find solutions for them.

May I ask how to make the typing for the class then, its just for autocomplete purposes.

I dont think its this

export type ComponentClass<T> = typeof(setmetatable({} :: {
    __instance: T
}, ComponentClass))
1 Like

I updated the code to export the type class for you to use!

Edit: I initially did that at the start but for some reason it was giving me DataModel before instead of instance type. I probably typed something wrong the first time around but it now works as expected.

1 Like
local Class = require("./class")

local Component = {}

function Component.Get<T, K>(self: Class.Class<T, K>)
	return getmetatable(self).__instance 
end

function Component.SetProperty<T, K, U>(self: Class.Class<T, K>, property: string, value: U)
    local instance = self:Get()
    local _, success = pcall(function()
        return (instance :: any)[property] and not (instance :: any):FindFirstChild(property)
    end)

    if success then
        (instance :: any)[property] = value
    end
end

So like this?

1 Like

It pretty much looks good, yeah. I also updated the sample code in the original post (see method FullIntellisense).

Read Usage & Example.

1 Like

Oh yea I saw it, btw I’m talking about when writing class Intellisense. Btw nice job I really like your module!

Because I’m used to use like class.get(self, …) instead of class:get(…) when writing module

2 Likes

Do you know if I write it right for self type when writing a class?

Since for you, you use like class:method(…), but as you can see I write it with class.method(self, …) for intellisense, so how is it for the typing?

1 Like

I essentially have to redeclare self to have proper type checking. I also do function Class.method(self, ...) but it’s just syntatic sugar in the end.

function Class:Method(): ()
    local self: Class<Instance, typeof(Class)> = self
    -- though LSPs/linters wont like shadowing variables,
    -- so either rename the variable or do function Class.Method(self)
end
1 Like

This is really cool. Ive been wanting something like this for a while, but never knew how to do it. I didnt even know types allowed this.

Although, would this cause memory issues if used (for instance) for every player in a 30 player server?

Also, is there any reason not to have the type set like this:

local PlayerClass = {
	Loaded = false,
}

type PlayerClass = Player & typeof(PlayerClass)

As opposed to what you suggested:

local ServerStorage = game:GetService("ServerStorage")

local Class = require(ServerStorage.Class)

local PlayerClass = {
	Loaded = false,
}

type Class<instance, class> = Class.Class<instance, class>
type PlayerClass = Class<Player, typeof(PlayerClass)>

I didnt notice a difference, but Im asking just in case

2 Likes

Well as long as you remove any references it should be properly garbage collected. Otherwise, feel free to let me know or modify if you need to. This is nothing but a table class in the end.

And yeah, you can do that
(I posted an example under the method NearFullIntellisense) Read Usage & Example
but it’s more for those who also wants intellisense for getmetatable(class) or those who needs the absolute type class.

Edit: Correct me if I’m wrong, but I believe you have to order it as typeof(PlayerClass) & Player as opposed to Player & typeof(PlayerClass) or else your override methods won’t show on autofill.

2 Likes

My only concern is that if I want to use a class with properties for multiple instances, I have to clone the entire class, which is a bit unusual compared to more traditional OOP modules (which usually have a constructor function). Of course you would have to set the properties anyway, but the functions being cloned is slightly worrying. I dont think it would matter enough to be an issue, but I dunno

You are right, I forgot to test that :sweat_smile:

1 Like

That is totally fair, and it shouldn’t really be an issue. When you decide to use table.clone() or a custom clone, all you are doing to non-table values is simply this:

Read FAQ.

If you have a function, you’re not actually cloning that function, but simply pointing to that function reference. 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.

Read FAQ.

Additionally, 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).

Read FAQ.

Edit: Thank you for the questions by the way, I will probably update the original post to address all these concerns and to format it better :smiley:

Edit 2: FAQ has been updated!

2 Likes