Roblox custom types and classes

Greeting Roblox Devs !


So I’m in love with oriented object programming, and now i want to talk about the Roblox usage. So I tried to figuring out how to make my own classes, my own types with custom methods and functions for them, and now it is time to share everything with you on how to make your own types and classes.

So I have read thoses articles :
https://devforum.roblox.com/t/all-about-object-oriented-programming/8585
https://devforum.roblox.com/t/i-need-help-with-my-custom-datatype/1707230/28?page=2
https://www.lua.org/pil/13.html

And both are pretty usefull, you can check them out, but here im going to merge all my knowledge to explain as simplier how to make good types and classes in roblox.


So for people that doesnt know what is a class, it is basically a variable with common functions and variables with other object of the same class.

It looks confusing, but basically every Instance in roblox studio are classes. So basically, you can access like playerService:GetPlayers() with is a function, or playerService.MaxPlayers the variables.


Types are specific to luau. It’s basically a pattern for creating classes, so basically allows you to create a pattern with multiple types inside.


Type’s variables

So let’s get into it. Lets say we want to create a type for a person, so we will first have the Name of the Person, and for example his age.

This is how to do it :

type Person = {
  Name: string,
  Age: number
}

So here, we say “ok so if we have like a person, we want it to have a Name that is a string, and an age which is a number”

So its pretty straight forward.

Now we can create a function in order to create a person, we will call it a Constructor, so basically, we want it to return a new created person, so to do that, we can do

local function PersonConstructor(): Person
  local newPerson: Person = {}
  return newPerson
end

Here when we call our function, we will get a new person ! Empty with nothing inside, but a new person !

Now for it to be functionnal, we will give arguments to the Constructor and fill our new Person. So we can just provide for example just a name and an age for us to fill our Person object.

local function PersonConstructor(name: string, age: number): Person
  local newPerson: Person = {}
  newPerson.Name = name
  newPerson.Age = age

  return newPerson
end

That seems better and usefull ! Now we can create a Person if we call this Constructor ! That is how to do it :
local paul = PersonConstructor('Paul', 18)
local tom = PersonConstructor('Ninon', 12)

print(tom.Age)    --> 12
print(paul.Name)  --> Paul

Now this is usefull, but we can do even by using functions for each players !


Type’s functions

So in our types, we added variables we can access like Name and Age, but we can also call local functions inside ! So to type the function, this is how basically it works :

type Person = {
  Name: string,
  Age: number,

  Birthday: (self) -> number,
  Greets: (self, who: Person) -> ()
}

So basically to explain the types, when it’s a function it is basically between parenthesis the arguments, and then the return value’s type.

So for example with the birthday’s function, we want self as parametter, which is basically the player that calls the functions ( i will explain later about self ) and it returns a number. Here we want it to return the new age after we increments it. So lets create the function :

local function PersonConstructor(name: string, age: number): Person
  local newPerson: Person = {}
  newPerson.Name = name
  newPerson.Age = age

  function newPerson:Birthday(): number
    self.Age += 1
    return self.Age
  end

  return newPerson
end

So now we can call the function like this !

local paul = PersonConstructor('Paul', 18)
print(paul.Age) --> 18
paul:Birthday()
print(paul.Age) --> 19

So to explain, further, when you create functions, you can have normal functions with . which have pretty straight forward arguments. Basically you can use them like this :

function Foo.Add(a: number, b: number): number
  return a + b
end

But you can also have something called method functions with :, here, it have another default argument called self. Basically, it is the object you used when calling the function.

function test:Hello()
  return self
end

print(test:Hello()) --> test

Here, the object that calls the function is the test object, so it is also the self.

Note that the : can be used with . if you give self as the first parametter :

function test:Hello()
  return self
end

--> Is the same as
function test.Hello(self)
  return self
end

--> And also the same as
test.Hello = function(self)
  return self
end

So now for the greeting part, we can code it like this :

local function PersonConstructor(name: string, age: number): Person
  local newPerson: Person = {}
  newPerson.Name = name
  newPerson.Age = age

  function newPerson:Birthday(): number
    self.Age += 1
    return self.Age
  end

  function newPerson.Greets(self, who: Person)
    print(self.Name .. ' says Hello to ' .. who.Name)
  end

  return newPerson
end
local person1 = PersonConstructor('Paul', 18)
local person2 = PersonConstructor('Tom', 12)

person1:Greets(person2) --> Paul says Hello to Tom
person1.Greets(person2, person1) --> Tom says Hello to Paul

Note that in the second usage of Greet, if you call as a normal function, you need to specify the self by hand, and the called, person1, is then useless, unless to provide the method.

Now this stuff is common, but now we can get in stuff EVEN BETTER AND FUNNIER ! Thanks to the wonderfull world of metadatas.


Type’s metadatas

Meta datas are basically stuff that allows you to watch an object. You can basically interract with anything, and metadatas will detect this, and you can do usefull and funny things with it.

For example, if you try now to print our person, what will it do ? Something ugly :

local elon = PersonConstructor('Elon Musk', 52)
print(elon) --> {...}

It will look like you’re print a dictionnary or a table. Now you can change this by using metatables like this :

local function PersonConstructor(name: string, age: number): Person
	local newPerson: Person = {}
	newPerson.Name = name
	newPerson.Age = age

	function newPerson:Birthday(): number
		self.Age += 1
		return self.Age
	end

	function newPerson.Greets(self: Person, who: Person)
		print(self.Name .. ' says Hello to ' .. who.Name)
	end
	
	local mt = {
		__tostring = function (self)
			return "This, is " .. self.Name
		end,
	}
	setmetatable(newPerson, mt)
	return newPerson
end
local elon = PersonConstructor('Elon Musk', 52)
print(elon) --> This, is Elon Musk

So im not here to fully explain the metatables, but basically it is hidden tables inside other tables that will check for custom actions before doing anything.
For example here, the custom action is tostring, so whenever we are trying to convert our object to string, it will first look in metatables if there is a string convertion, else it will print the default table.

But we can for example detect you add 2 Person together, to create a baby !

    ...

	local mt = {
		__tostring = function (self)
			return "This is " .. self.Name
		end,
		__add = function(self, t2: Person)
			return PersonConstructor('Baby ' .. self.Name, 0)
		end,
	}
	setmetatable(newPerson, mt)
	return newPerson
end
local john = PersonConstructor('John', 23)
local lea = PersonConstructor('Lea', 22)

local baby = john + lea
print(baby) --> This is baby

You can also detect like for example when a value changes, for example if you want a Visible property:

    ...

    local proxy = {}
	local mt = {
		__index = proxy,
		__newindex = function(_, key, value)
			if proxy[key] == nil then proxy[key] = value return end
			print("Changed " .. tostring(key) .. " -> " .. tostring(value))
			proxy[key] = value
		end,
		__tostring = function (self)
			return "This is " .. self.Name
		end,
		__add = function (self, t2: Person)
			return PersonConstructor('Baby ' .. self.Name, 0)
		end,
	}
	setmetatable(newPerson, mt)
    return newPerson
end
local john = PersonConstructor('John', 23)
john.Name = 'Hello' --> prints Name Changed

Note, if you watch carefully, you’ll see that i used proxy metatables. There is a reason because if you dont do that, it won’t detect values already added.

14 Likes

There’s a reason why people don’t do this.

In theory, if no upvalues are used, this function should be cached in memory, but if you decided to add “private properties,” the following will happen:

local function New()
	local self = {}
	local PrivateProperty = 1
	
	function self:Method()
		PrivateProperty = 2
	end
	
	return self
end

local ClassA = New()
local ClassB = New()

-- false; they are two different functions
print(ClassA.Method == ClassB.Method)

And even if you did make sure no upvalues were used and these functions were cached (they would be rawequal), you would still pay for having an extra function stored in each ‘self’ table.


For efficiency’s sake, just write classes with the idiomatic metatable approach. They’re not pretty, but at least you’re not going to get 100 clones of the same method.

6 Likes

Very useful and nice explanation

1 Like

You are tottally right !

I think that to avoid this, we can just make sure that the constructor is inside a dictionnary ( basically inside a class ) and set the metatable to this class in this way :

local PersonClass = {}  -- The class table

function PersonClass.Constructor(name: string, age: number): Person
    local newPerson: Person = {
        Name = name,
        Age = age,
    }

    --> Here
    setmetatable(newPerson, { __index = PersonClass })
    
    function newPerson:Birthday()
        self.Age += 1
        return self.Age
    end    
    return newPerson
end

local person = PersonClass.new("John", 25)
print(person:Birthday())

In this code, the birthday will now be shared among all the instances of the classes. Ill update everything once ill be back.

Thanks a lot for reported this issue, and tell me if other problems occurs in the code !