Classes & Metatables for Dummies: How metatables work under the hood to replicate classes in other languages

Introduction

When I first decided to start scripting, metatables seemed like a forbidden art. It didn’t make sense to me, but since I would copy and paste other people’s code anyway, it didn’t matter if I understood how they worked.

I wanted to make this guide for my past self. By the end, you should have some intuition of how metatables work. You don’t need to understand all of it now, but I recommend you at least understand how tables and functions work. You should also reference the documentation on metatables. Even though I’ve understood them for a long time, I still refer back to that documentation when I’m working with metatables. If part of this guide becomes confusing, refer to the links on those particular topics, then return once you’ve touched up on your knowledge.

Note: In this guide, you will see many examples. I recommend you run and play with them in scripts, the developer console, or the console inside Roblox Studio. If those options are unavailable for you, you may also run them in the Luau Demo on the Luau official website.

Note on Tables

Since I didn’t see this explained well in the documentation mentioned above on tables. When you have a dictionary where the keys are strings, you can reference the key by doing:

local Table = {
	["Value"] = 1
}

print(Table["Value"])
print(Table.Value)

Both print statements will output the value 1. It’s also important to note that you could’ve defined Table like so, and it would be the same:

local Table = {
	Value = 1
}

You could have also defined it like this:

local Table = {}
Table.Value = 1

With that out of the way, let’s begin with the guide.

How do Metatables Work?

By the end of the guide, you should understand how this code works:

local Person = {}
Person.__index = Person

function Person.new(Name, Age)
	local self = setmetatable({
		Name = Name,
		Age = Age
	}, Person)
	
	return self
end

function Person:Hello()
	print(`Hello, {self.Name}! You are {self.Age} years old!`)
end

local John = Person.new("John", 20)
John:Hello()

Let’s begin with the simplest of metatables:

local Table = setmetatable({}, {})

This table effectively works like a standard table.

Note: You don’t need to understand why that is for now since that’s the whole point of this guide.

I recommend you try it! For example:

local Table = setmetatable({}, {})
Table.Value = 1

print(Table.Value)

You could try many different things, but as stated before, it behaves like a normal table. You can also define the table in the second argument in any fashion you please, but it won’t affect anything unless you know what you’re doing. For example:

local Table = setmetatable({}, {1, 2, 3, 4, 5})
Table.Value = 1

print(Table.Value)

This will still print the value 1. If you want to get the value of the second argument from Table, you can use getmetatable. For example:

local Table = setmetatable({}, {
	Value = 1
})

print(getmetatable(Table).Value)

This will also print the value 1.

So why have this setmetatable thing that complicates everything?

I hear you asking, and with the example I presented above, you are right. You can save yourself the trouble and instead do the following:

local Table  = {}
Table.Value = 1

print(Table.Value)

This code will function precisely the same.

To explain why setmetatable even matters, we must define the two arguments you pass into the function. The first argument is the actual table, and the second argument is where we define something called metamethods. You don’t need to know what those are now, but they are the “special sauce” that makes metatables, well, special.

Let’s start with that first argument. In it, you can define the values of the table as you would with a normal table:

local Table = {
	Value = 1
}

With setmetatable:

local Table = setmetatable({
	Value = 1	
}, {})

These two tables are identical. If you were to…

print(Table.Value)

… on either of the Tables I defined above, it will print the value 1.

Since that’s now established, I’ll only use tables defined in setmetatable. Now, let’s talk about that second argument. Referring to the documentation on metatables, we can see:

Metamethods are the functions that are stored inside a metatable. They can go from calling a table, to adding a table, to even dividing tables as well.

Below that, we also see the metamethods we have available to us. To name a few, we have __index, __call, __tostring, and __len. For this guide, we’ll only focus on the __index, since it’s the most common one you will see in the wild since they’re used to emulate something called classes. You don’t need to worry about it for now, as I’ll explain it later.

Finally, with that preamble out of the way, let’s define our first-ever metamethod:

local Table = setmetatable({}, {
	__index = function()
		return 1
	end,
})

You will see no results if you run this code, but we’ve done something interesting here. With that Table defined, if you do:

print(Table.Value)

You will see that it prints 1. Try to index any value, such as 100, nil, or "Hello", and it’ll all work the same:

print(Table[100])
print(Table[nil])
print(Table["Hello"])

If that’s confusing to you, think of __index as any normal function that you can define in a table:

local Table = {}
Table.__index = function()
	return 1
end

print(Table.__index())

The example above will print the value 1. I might have lost you, but I will de-mystify this magic in a second. In the documentation for __index, we see:

Fires when table[index] is indexed, if table[index] is nil. Can also be set to a table, in which case that table will be indexed.

We’ll get back to the second sentence later, but the first sentence matters to us now. Since all of those index values don’t actually exist inside of the table, we defined (we neither defined it in the first argument in setmetatable or after), and our __index function is called instead. Now, it’s our job to define what is returned instead. To demonstrate this a little better, take a look at this example:

local Table = setmetatable({
	Value = "Hello"	
}, {
	__index = function()
		return 1
	end,
})

print(Table.Value)
print(Table[100])

The first will print the value "Hello", and the second will print the value 1.

That’s great, but this seems like a fancy gimmick. What can we do with this?

Well, if you refer to the metastable documentation again, you will see that the function call to __index gives us two arguments:

__index(table, index)

The first is the original table (the first argument that we initially passed into the setmetatable function, and the value the person tried to index but failed (because it didn’t exist). Let’s use that now in practice:

local Table = setmetatable({
	Value = "Hello"	
}, {
	__index = function(Table, Index)
		print(Table, Index)
		return 1
	end,
})

local Variable = Table[100]

If you run this code, you will see this printed to the console:

{
	["Value"] = "Hello"
} 100

Doing something with the value of Index now, we can do something like:

local Table = setmetatable({}, {
	__index = function(Table, Index)
		return Index
	end,
})

print(Table[100])

Now, no matter what value you try to index for Table, it will always print the value you tried to index.

You may also bypass the __index metamethod by using rawget:

local Person = {
	__index = function(Index, Value)
		return 1
	end
}

local Table = setmetatable({Value = "Hello"}, Person)

print(rawget(Table, "Value"))

This will print the value "Hello". There are similar functions to rawget, such as rawset, which works on the __newindex metamethod.

Note: I’ll include some examples below to help illustrate how __index works, especially for those already familiar with it. But don’t worry too much if it doesn’t make sense right away.

It’s important to remember that even though __index uses this fancy metatable, it’s just a typical function call. If this helps, you should consider it like an event like ChildAdded. If ChildAdded fires, the function you pass into the Connect function gets called with the argument of the instance that was added:

workspace.ChildAdded:Connect(function(Child)
	print(Child.Name)
end)

With metamethods (and consequently, our __index function we defined), we define a function that is called, and when it is, we return the value that should be returned instead. Another closer example is with BindableFunctions:

local Event = Instance.new("BindableFunction")
Event.Parent = workspace

Event.OnInvoke = function(Player, ...)
	return 1
end

Instead of being given a Player object and the values called when you call Invoke on the BindableFunction, we get the table and the index into the function we defined in __index when the value is nil.

That was a lot to process, but I hope that makes sense. Moving on from this, let’s present a different example with setmetatable. For the most part, with all the examples we have worked with thus far, we have passed all the arguments inside of the function call itself. Let’s do something different:

local Person = {
	__index = function(Index, Value)
		return 1
	end
}

local Table = setmetatable({}, Person)

print(Table.Value)

Since we’ve defined the metamethod for __index inside of the table Person and passed that in as the second argument in setmetatable, it will print the value 1, just like before.

This will also work the same way, since we’re similarly defining the function, but later, rather than when Person is initially defined:

local Person = {}
Person.__index = function(Index, Value)
	return 1
end

local Table = setmetatable({}, Person)

print(Table.Value)

If I haven’t lost you yet, let’s go back to that second sentence from the metamethod documentation for __index:

Can also be set to a table, in which case that table will be indexed.

Let’s do that here:

local RealTable = {Name = "John"}

local Person = {}
Person.__index = RealTable

local Table = setmetatable({}, Person)

print(Table.Name)

This is effectively the same as if we did:

local RealTable = {Name = "John"}

local Person = {}
Person.__index = function(Table, Index)
	return RealTable[Index]
end

local Table = setmetatable({}, Person)

print(Table.Name)

This shouldn’t be surprising, but I’ll explain what happened again if you’re lost. We defined a metatable with an empty table for the first argument and the Person table for the second argument. The Person table has a value defined for __index, which is set to our RealTable table. Table inception, as I like to call it.

When we try to index Name inside of Table, the value for Name doesn’t exist as it was never defined. Instead, it looks to what we defined in __index, which we pointed to RealTable. Since RealTable has Name defined as "John", the value "John" is returned from the __index and it prints out the value "John".

Instead of defining RealTable, we can define Name inside of Person and point __index to Person instead:

local Person = {Name = "John"}
Person.__index = Person

local Table = setmetatable({}, Person)

print(Table.Name)

This example will also print the value "John". You should fully understand how __index works by now. The same knowledge you learned here can be applied to the metamethods defined in the metatable documentation. As a challenge I leave for you at the end of this section, I recommend you mess with some of the other metamethods.

Classes

Explaining object-oriented programming (or OOP) is far beyond the scope of this guide. If you want to dive deep into that topic, many resources are available, and I recommend you do your own research. For this guide, I’ll explain that it’s a way of programming that is very common in other languages and doesn’t exist as a first-class feature inside of Lua (or Roblox’s version of Lua, Luau). Since it’s a popular programming style, especially in languages such as C++ and Java, people wanted to develop ways to emulate that style with Lua. They do this by using the __index method I mentioned earlier, and you can see this in the example at the beginning of the guide.

We will start where we left off in the previous section. If this code doesn’t make sense to you and you haven’t read the previous section, I recommend you do so now:

local Person = {Name = "John"}
Person.__index = Person

local function New()
	local self = setmetatable({}, Person)
	
	return self
end

local Table = New()
print(Table.Name)

This works the same as where we left off previously. The only thing I’ve done is move the setmetatable inside of the function New. Instead of being called Table inside of New, I called it self. It’s called this way because it’s just a convention, but you can call it whatever you want.

Now, let’s move on to this:

local Person = {Name = "John"}
Person.__index = Person

Person.New = function()
	local self = setmetatable({}, Person)
	
	return self
end

local Table = Person.New()
print(Table.Name)

We have now moved our New function to be a part of Person instead of its own standalone function. Now, to make it look more like the example at the beginning of this guide:

local Person = {Name = "John"}
Person.__index = Person

function Person.new()
	local self = setmetatable({}, Person)
	
	return self
end

local Table = Person.new()
print(Table.Name)

This works the same way as the code preceding it, except we have renamed New to new. As you might’ve guessed, this is merely a convention, so you can call this function whatever you want.

Now, to define our first function:

local Person = {}
Person.__index = Person

function Person.new()
	local self = setmetatable({
		Name = "John"
	}, Person)
	
	return self
end

function Person:Hello()
	print(`Hello, {self.Name}!`)
end

local Table = Person.new()
Table:Hello()

Now, this might seem like a huge step, but I will explain what happened here. We moved Name to that setmetatable function call we defined inside of self instead of being part of Person. Hello was called, Hello was not a member of self that was returned by new. However, since Hello was defined in Person, it returned that function instead.

You may notice that we defined the function Hello as a part of Person using the colon notation (:) instead of with dot notation (.). This is discussed more in detail here if you are interested, but the important thing to note here is that the function definition for Hello could also have been defined like:

function Person.Hello(self)
	print(`Hello, {self.Name}!`)
end

Additionally, when we called Hello from Person using colon notation, the default argument passed into the function is self, or the table that you called the function on. The reason why it’s called self is because it refers to the table it was called on, but you can call self whatever you want. That function call could also have looked like this:

local Table = Person.new()
Table.Hello(Table)

It’s important to note that you cannot have a function defined in Person in this example with the same name as an index you define inside of the setmetatable call. For example:

local Person = {}
Person.__index = Person

function Person.new()
	local self = setmetatable({
		Hello = "John"
	}, Person)
	
	return self
end

function Person:Hello()
	print(`Hello, {self.Hello}!`)
end

local Table = Person.new()
Table:Hello()

This code would error with an error like:

attempt to call a string value

This is because now that Hello is defined inside the setmetatable function call, the __index function is never called since it isn’t nil. Remember what the documentation on __index said about it:

Fires when table[index] is indexed, if table[index] is nil. Can also be set to a table, in which case that table will be indexed.

You could also work around this by doing:

local Person = {}
Person.__index = Person

function Person.new()
	local self = setmetatable({
		Hello = "John"
	}, Person)

	return self
end

function Person:Hello()
	print(`Hello, {self.Hello}!`)
end

local Table = Person.new()
Person.Hello(Table) -- This is the line that changed.

This should also give some more insight into how the original code works, which I will re-define here, but with a slight modification:

local Person = {}
Person.__index = Person

function Person.new(Name)
	local self = setmetatable({
		Name = Name
	}, Person)
	
	return self
end

function Person:Hello()
	print(`Hello, {self.Name}!`)
end

local Table = Person.new("John")
Table:Hello()

Now, we pass the value "John" into the new function call, with the parameter Name defined and set Name inside of the setmetatable to that value.

Now, at the end of this all, the original example I said you would understand at the beginning should make sense:

local Person = {}
Person.__index = Person

function Person.new(Name, Age)
	local self = setmetatable({
		Name = Name,
		Age = Age
	}, Person)
	
	return self
end

function Person:Hello()
	print(`Hello, {self.Name}! You are {self.Age} years old!`)
end

local John = Person.new("John", 20)
John:Hello()

I may sound like a broken record, but I’ll explain this again from the beginning. John is set to the result of the Person.new function call, where we passed in the arguments "John" and 20 for Name and Age respectively. Then, in a variable we defined as self, we called setmetatable and set the Name and the Age inside it to the values passed into the function. We set Person as the second argument of the setmetatable call, which also has __index set to the Person table, where __index allows us to override what is returned as a value when the key/index doesn’t exist (or more colloquially, is nil). We then return self from new, which puts us where we called Hello.

Here, since Hello doesn’t exist inside of the self that was returned from new, it looks to the Person table since we pointed __index to that. Since we defined Hello as a function of Person, __index returns that function and the function call is valid. Since we used colon notation, self is passed in as an argument to the function call. Since self did have Name and Age defined in it in new originally, it works when we try to index them now inside of Hello.

Now you know how the original example at the beginning works.

Note on Type Checking

Type checking isn’t required to understand classes, but Roblox’s guide on it here explains how it works. The official Luau website has a more detailed type checking guide here.

You are recommended to try this but to save yourself the trouble if you’re too lazy, most examples I’ve provided throughout this guide type check perfectly fine. This is because the type inference engine is smart and can infer the types in most cases. However, the type inference engine isn’t smart enough to guess the types in this case. Why this doesn’t work is explained better here. To make the code above type check properly, you would have to re-define the code like so:

type PersonImpl = {
	__index: PersonImpl,
	new: (Name: string, Age: number) -> Person,
	Hello: (self: Person) -> (),
}

type Person = typeof(setmetatable({} :: { Name: string, Age: number }, {} :: PersonImpl))

local Person: PersonImpl = {} :: PersonImpl
Person.__index = Person

function Person.new(Name: string, Age: number): Person
	local self = setmetatable({
		Name = Name,
		Age = Age
	}, Person)

	return self
end

function Person:Hello()
	print(`Hello, {self.Name}! You are {self.Age} years old!`)
end

local John = Person.new("John", 20)
John:Hello()

The Luau team is constantly working on improving type checking, so this may not be necessary at some point in the future.

Conclusion

You’ve made it to the end :tada:! I spent a lot of time making this guide, but I’m happy with how it turned out. Let me know if you have any suggestions on how I could improve it. Also, please give me your feedback! Let me know what parts of the guide you struggled to understand so I can improve them. Also, let me know if you found this information useful or valuable.

You are also free to ask me questions in the replies to this post.

Have a great day!

14 Likes