Simplest form of instance wrapping that "just works"

github link (paste into a module)

Disclaimer: I know the title says “simplest” but this might not be actually simple. I’d consider it simple in the sense that it is a bare-minimum way of creating instance wrappers that “just work”.

What is instance wrapping?

The topic of instance wrappers has been covered before, with different techniques to create wrappers with varying levels of functionality, really in-depth wrapper here, but this guide serves to get you a “good enough” wrapper with like 10 lines, that covers all the common use cases (properties + functions + methods) using a little bit of metatable redirection (with exceptions at the bottom of the post).
──────────────────────────────────────────────────────

End Result:

local CustomWorkspace = CreateWrapper(workspace)
CustomWorkspace.MyCustomProperty = 10
function CustomWorkspace.HelloWorld()
	print("Hello World!")
end
function CustomWorkspace:PrintCustomProperty()
	print(self.MyCustomProperty)
end

print(CustomWorkspace.Gravity) --> 196.199996
print(CustomWorkspace:FindFirstChild("Terrain")) --> Terrain
print(CustomWorkspace:Raycast(Vector3.new(0,5,0), Vector3.new(0,-10,0))) --> RaycastResult{Baseplate...}

print(CustomWorkspace.MyCustomProperty) --> 10
CustomWorkspace.HelloWorld() --> Hello World!
CustomWorkspace:PrintCustomProperty() --> 10

──────────────────────────────────────────────────────

Bare bones instance wrapper (20 lines):

Instance wrapper
local WrapperMetatable = {}
WrapperMetatable.__index = function(wrapper, key)
	local instance = rawget(wrapper, _instance)
	if type(instance[key]) == "function" then
		return function(FirstParameter, ...)
			if FirstParameter == wrapper then --this is a method call
				return instance[key](instance, ...) --invoke with instance as "self" instead of wrapper
			else --normal function call
				return instance[key](FirstParameter, ...)
			end
		end
	else
		return instance[key]
	end
end

local function CreateWrapper(instance: Instance)
	local NewWrapper = setmetatable({}, WrapperMetatable)
	NewWrapper._instance = instance
	return NewWrapper
end
Explanation

When indexing into CustomWorkspace with a key that doesn’t exist inside of the wrapping table (i.e. a key that is a property or function name of the actual workspace), the lua interpreter looks up the metatable and finds the __index key, which is set equal to a special function. It invokes the function with two parameters: the thing it was trying to index into (i.e. CustomWrapper which is passed into the wrapper param), and the key it was attempting to use.

The first line of this function indexes into the wrapper with the _instance string key. There is nothing special about this key (not a metamethod), and it could be called anything, but this is just where I decided to put the reference to the actual instance the wrapper is wrapping. It indexes into the instance with the key, and if this ends up grabbing a function (i.e. stuff like FindFirstChild), it creates and returns new anonymous function.

--normally
local fn = workspace["FindFirstChild"] --> this will grab the FindFirstChild function

--special anonymous function
local custom_fn = CustomWorkspace["FindFirstChild"] --> this grabs the anonymous function

This new anonymous function exists for one single purpose. When invoked, it will check the parameters that it was invoked with and do a check to see if the first parameter is the specific wrapper that it was created for. If this is the case, that means the wrapper was attempting to do a method call, i.e. :

CustomWorkspace:FindFirstChild("Terrain")

is just syntactic sugar for (identical to)

CustomWorkspace.FindFirstChild(CustomWorkspace, "Terrain")

You don’t want that implicit first parameter of FindFirstChild to be the CustomWorkspace, instead it should be the actual workspace, so if the anonymous function sees that the first parameter is the wrapper, it invokes FindFirstChild (i.e. instance[key]) with the first parameter being instance (i.e. workspace in this example), then the rest of the ... parameters afterwards.

If its not a method call (which might be the case if you try to wrap a singleton class that has regular functions instead of methods), then the anonymous function just invokes the instance’s function with no changes to the call (FirstParameter, ... are directly passed to instance[key] )


Now in the case where instance[key] is not a function (i.e. the key refers to a property of workspace such as .Gravity, or a child such as Terrain)
Then it just directly indexes into instance[key] and returns whatever happens to be there

Trivial instance wrapper that doesn’t work with method calls (2 lines):

Trivial wrapper
local WorkspaceWrapperMetatable = {__index = workspace}
local CustomWorkspace = setmetatable({}, WorkspaceWrapperMetatable)

--Tests
CustomWorkspace.MyCustomProperty = 3
print(CustomWorkspace.MyCustomProperty) --> 3
print(CustomWorkspace.Terrain) --> Terrain
print(CustomWorkspace:FindFirstChild("Terrain")) --> ERROR Expected ':' not '.' calling mem...
Explanation

When indexing into CustomWorkspace with a key that doesn’t exist inside of the wrapping table (i.e. a key that is a property or function name of the actual workspace), the lua interpreter looks up the metatable and finds the __index key, which points to the userdata workspace object. It passes the key to the real workspace and lets it do its thing. This breaks when you make a method call because:

something:method()

is really just syntactic sugar (the same as) this:

something.method(something)

so any method calls such as FindFirstChild really end up doing

CustomWorkspace.FindFirstChild(CustomWorkspace, "Terrain")

which is not what you want, since even though the lua interpreter is able to retrieve the FindFirstChild function from the workspace, it’s passing in the wrong first parameter, and the FindFirstChild implementation has no clue what “CustomWorkspace” is.

──────────────────────────────────────────────────────

How to have your custom wrappers inherit custom functions/properties without directly setting it

Don't use multi-inheritance!!!!!

Normally, you can achieve multiple levels of inheritance by doing something like:

bird = {}
bird.__index = bird
pigeon = {}
pigeon.__index = pigeon
setmetatable(pigeon, bird)

billy = setmetatable({}, pigeon)
--interpreter will look for keys in billy first, then its metatable pigeon,
--then pigeon's metatable which is bird

However Warning DO NOT setmetatable some table to WrapperMetatable

--DON'T DO THIS!
local CarWrapperClass = {}
CarWrapperClass.__index = CarWrapperClass
setmetatable(CarWrapperClass, WrapperMetatable)

local NewCar = setmetatable({}, CarWrapperClass)
NewCar._instance = workspace.CarModel
print(NewCar.Roof.Color) --> Attempt to index nil with Roof

It encounters an error because when a key cannot be found in NewCar, the interpreter looks in CarWrapper, can’t find it there, then looks invokes the __index function inside the WrapperMetatable. However instead of invoking it with the topmost level NewCar wrapper object as the table, the CarWrapperClass table is passed instead, which has no _instance key (the _instance key is part of the NewCar, NOT CarWrapper).

These wrappers don’t work with multi-inheritance!

Just place any inherited functions/properties/keys directly in the WrapperMetatable, and have the __index function check for those inherited keys, with this one extra line:

local CarClass = {}
CarClass.__index = function(wrapper, key)

	if CarClass[key] then return CarClass[key] end --add this line here

	local instance = rawget(wrapper, "_instance")
	if type(instance[key]) == "function" then
		return function(FirstParameter, ...)
			if FirstParameter == wrapper then --this is a method call
				return instance[key](instance, ...) --invoke with instance as "self" instead of wrapper
			else --normal function call
				return instance[key](FirstParameter, ...)
			end
		end
	else
		return instance[key]
	end
end

local function CreateCar(instance: Instance)
	local NewCar = setmetatable({}, CarClass)
	NewCar._instance = instance
	return NewCar
end

Now you can define any function or property in CarClass and it will work as expected:

function CarClass:Drive()
    print(self.CarName, "is driving")
end

local NewCar = CreateCar(workspace.CarModel)
NewCar.CarName = "honda civic"
NewCar:Drive() --> honda civic is driving
print(NewCar:FindFirstChild("Roof").Material) --> Metal

This technique for key redirection is inspired by this StackOverflow post. I found this post when I was trying to make the trivial wrapper work with method calls.

Exceptions:
Because this wrapper works through redirection, any method calls like Clone() and Destroy() will be passed directly to the underlying instance. That means that you shouldn’t expect Clone() to create a copy of the wrapper object, instead it will just clone the underlying instance and return that instance.

Destroy calls will destroy the underlying instance and remove it from the datamodel, but the instance won’t get garbage collected until the wrapper goes out of scope (since the wrapper points to the instance through its _instance key). This isn’t necessarily a memory leak, since as long as the wrapper isn’t a global and you’re doing cleanup of the wrapper whenever you want to destroy the object its wrapping, there’s no problems, but if this issue is important to you, you can see the in-depth wrapper for a guide on how to use weak references.