My Approach for OOP in Luau

Hey all,

Back when I was first learning OOP in Luau a few months ago, I felt like the best practices were not documented, not obvious, and not searchable. I still can’t really find anything on this forum. I sort of had to muddle through using trial & error until I found a way to satisfy the compiler. I’m using this post to share what I’ve settled on as a good solution so hopefully others don’t need to go through the 5 iterations I did to get here. This post is written from the perspective of someone who was happily using OOP in Lua prior to the Luau type additions, so if that’s not you, maybe this won’t make any sense.

I’m not necessarily asserting that this is the best way, but it’s a way that makes me happy.

So, without further ado, some of the goals I set out to accomplish:

  • The Luau typechecker yells at me when I mess up types. This seems like a given. And inversely, if I don’t mess up types, I don’t want any warnings.
  • Intellisense will chime in to help me remember methods & members.
  • I can utilize the __index metatable, so I don’t need to incur a big ol’ cost of construction where I copy in a bunch of methods to an object.
  • I want to be able to separate public & private members.
  • I want to be able to automatically deduce methods from the definition. Note, I was NOT able to accomplish this AND get public/private members, but if anyone can accomplish all of these goals, I’ll give you props. :slight_smile:

I’ll share with you the “template” that I copy/paste in for every new class I create, then explain each aspect of it.

--!strict

--[[ Class ExampleObject
	DESCRIBEME
--]]

local ExampleObject = {};
local ExampleObjectClass = {metatable = {__index = ExampleObject}};
export type ExampleObject = {
	ClassName: "ExampleObject";
	PublicVariable: number;

	PublicMethod: (self: ExampleObject) -> ();
	Destroy: (self: ExampleObject) -> ();
};
type _ExampleObject = ExampleObject & {
	_PrivateVariable: string;

	_PrivateMethod: (self: _ExampleObject) -> ();
};

ExampleObject.ClassName = "ExampleObject";

function ExampleObject._PrivateMethod(self: _ExampleObject): ()
	
end

function ExampleObject.PublicMethod(self: _ExampleObject): ()
	
end

function ExampleObject.Destroy(self: _ExampleObject): ()
	--TODO: Implement me
end

function ExampleObjectClass.new(): ExampleObject
	local self: _ExampleObject = setmetatable({}, ExampleObjectClass.metatable) :: any;
	self.PublicVariable = 6;
	self._PrivateVariable = "foobar";
	return self;
end

return ExampleObjectClass;

Class Objects

In my template, I have two tables for a given object: the __index table (ExampleObject), and the constructor table (ExampleObjectClass). Unlike traditional Lua OOP, where you assign the __index field to the class table itself, I realized I ought to split them apart so I don’t get bogus suggestions from intellisense.

The traditional pattern:

local ExampleObject = {};
ExampleObject.__index = ExampleObject;

This is what you get if you use that pattern:

Vl8VT5AS2T

With a separate __index table and constructor table, intellisense looks like this:

PS3eUJRjFb

This seems better!

Explicit Public & Private Type Definitions

In my template, I define two types:

  1. A public type, ExampleObject, which is exported from the module.
  2. A private type, _ExampleObject (note the underscore), which is NOT exported from the module.
    1. Note also, the private type is unioned with the public type, so if you have a handle to the private type, you also get access to all public members.

The benefit of this is that you’re only exposed to the methods & variables that you should be exposed to:

External to the module, you’ll just see the public members with intellisense:

RobloxStudioBeta_e09TMcweUU

Things to Watch Out For

When you define the methods in the type itself, you need to use the public type.

export type ExampleObject = {
-	PublicMethod: (self: _ExampleObject) -> (); --bad
+	PublicMethod: (self: ExampleObject) -> (); --good
};

Probably the biggest pitfall of this template is that failing to do this gives you a confusing error message.

If you stare at it long enough, you see the issue: obj is missing the method _PrivateMethod, because PublicMethod is expecting a private ExampleObject type, not the public type.

In the implementation, you can use the private type:

-function ExampleObject.PublicMethod(self: ExampleObject): () --meh
+function ExampleObject.PublicMethod(self: _ExampleObject): () --good

This is much less annoying a pitfall, as the only penalty is that you won’t be able to access private members without the typechecker yelling at you, but the fix is more obvious in this scenario.

image

ClassName member

One thing I added which has occasionally been valuable was the ClassName field. It’s set to a singleton type, and can be used to narrow intersection types.

It’s not essential, though, so if you want to remove it, you probably won’t miss it too much.

Method Implementations

These are pretty similar to the “old pattern” I described above. The big caveat here, though, is that you have to manually specify your self variable, otherwise you will not get the intellisense & typechecking that you want.

RobloxStudioBeta_Bc8LWzrNM2

If you accidentally include BOTH the explicit self variable & use a colon, you’ll get a pretty easy-to-understand error message:

RobloxStudioBeta_vgswoxY57R

A note on Destroy

In my template, I leave the Destroy method sitting there with a TODO. This is basically a continual reminder for me to implement this, because it’s really easy to forget about. If you have a different name for your method, or you live on the edge & never clean up your memory, you can change this boilerplate method or even remove it.

Constructor

Finally, in the constructor, I specify the function to return the public class type. The magic line here is this:

	local self: _ExampleObject = setmetatable({}, ExampleObjectClass.metatable) :: any;

Whenever you see a cast-to-any (:: any), you know you’re looking at shenanigans. Ignoring the fact that the typechecker does not expand metatable __index tables, and really isn’t doing this, we’re essentially casting an object of the shape:

{
    _PrivateMethod: (_ExampleObject) -> ();
    PublicMethod: (_ExampleObject) -> ();
    Destroy: (_ExampleObject) -> ();
}

To one of the shape:

{
    _PrivateMethod: (ExampleObject) -> ();
    PublicMethod: (ExampleObject) -> ();
    Destroy: (ExampleObject) -> ();
}

(plus variables, etc.)

Because of the distinct public and private members, I couldn’t really come up with a way to avoid this cast.

UNFORTUNATELY, this is the place where type errors CAN sneak in, though I typically find them pretty quickly. They occur when the signature of the implementation disagrees with the type definition up above.

Generally, the implementation will be correct, so you’ll end up finding this when you go to call your method and it’s not accepting the parameters you expected, or returning the types you expected.

Other Resources

I made a claim at the top of this post that it’s hard to find other resources on this. I just did another search and happened upon this post (which by sheer luck was posted two days before mine), which takes a slightly different approach. It doesn’t support private members, but it also does not perform “any-casting”, so… you may like it better. I personally lean heavily on private variables & private methods to organize my objects.

Conclusion

Anyway, I sort of wrote this in one sitting & because it’s stream-of-conscious explanation of a text file on my computer, it may not be totally clear. If this is helpful to anyone, let me know, and if you get confused anywhere, also let me know & I’ll see if I can clean this up at all.

One maybe trivial detail is that you’ll see I use the coding standard: PascalCase for public, _PascalCase for private. If you have slightly different preferences, definitely feel free to tweak the approach. You don’t even need to distinguish public & private functions by naming convention if you don’t wish to. The Luau types will do a lot of heavy lifting to discourage people from calling private methods.

As one final note, I’ll put my true template in a code block, since I don’t actually use the one I showed above – I modified it a bit to make it more illustrative. Feel free to take whichever.

Template
--!strict

--[[ Class v5 TODOCLASSNAME
	DESCRIBEME
--]]

local TODOCLASSNAME = {};
local TODOCLASSNAMEClass = {metatable = {__index = TODOCLASSNAME}};
export type TODOCLASSNAME = {
	ClassName: "TODOCLASSNAME";

	Destroy: (self: TODOCLASSNAME) -> ();
};
type _TODOCLASSNAME = TODOCLASSNAME & {

};

TODOCLASSNAME.ClassName = "TODOCLASSNAME";

function TODOCLASSNAME.Destroy(self: _TODOCLASSNAME): ()
	--TODO: Implement me
end

function TODOCLASSNAMEClass.new(): TODOCLASSNAME
	local self: _TODOCLASSNAME = setmetatable({}, TODOCLASSNAMEClass.metatable) :: any;
	return self;
end

return TODOCLASSNAMEClass;

I like big bold text to say: “this needs to be updated.”

23 Likes