Read-Only Tables for ModuleScripts! | Protected Table Utility

This is a short utility function that allows you to make tables that a ModuleScript exposes read-only to external scripts, but still editable in the module (the concept of “protected” from Object Oriented Programming (or I suppose “private” but technically it could be expanded to allow multiple scripts access to a exposed table so “protected”)).
I should mention that this code does not support nested tables - but it could be supported easily enough. (if you have multiple tables inside your protected table, you will have to make each one of them a protected table)

NOTE: another way of achieving the same result (with nesting support even) (but with 2 tables referencing the actual table, a local and a exposed one) is availible in the comments and it does fix the issue I mention later below - if you want a completely secure and potentially a bit faster solution, use that (it technically does use 1 more table of memory per protected table but that is pretty insignificant).

Usage example

INSIDE A MODULE SCRIPT

local module = {}

-- [protected table code here]

module.my_protected_table = ProtectedTable({
	Hello = "World"
})
-- table can be read
print(module.my_protected_table.Hello) --> "World"
-- table can be written to
module.my_protected_table.Apple = "Golden"

return module

INSIDE A SCRIPT USING THE MODULE

local module = require(script.ThatModule)

-- table can be read
print(module.my_protected_table.Apple) --> "Golden"
-- table CANNOT be written to
module.my_protected_table.Hello = "Hi" --> error: This table cannot be modified externally.

Code

There is no need to publish this as an actual script model (since its only about 50 lines with the extension functions below), simply paste this into any script you need it in.

local _initial_script_full_name = script:GetFullName()
local _ProtectedTables = {} :: {[number] : {any}}
local _ProtectedTableMeta = {
	__index = function (t : {__addres : number}, idx)
		return _ProtectedTables[t.__addres][idx]
	end,
	__newindex = function (t : {__addres : number}, idx, val)
		if debug.info(2, "s") == _initial_script_full_name then
			_ProtectedTables[t.__addres][idx] = val
		else
			error("This table cannot be modified externally.")
		end
	end,
}
function ProtectedTable<T>(actual : T) : typeof(setmetatable({} :: T, _ProtectedTableMeta)) 
	local addres = tonumber(tostring({}):sub(8)) :: number
	_ProtectedTables[addres] = actual
	return table.freeze(setmetatable({__addres = addres}, _ProtectedTableMeta))
end

It works by storing the actual table in a local table that acts as a registry of all protected tables. Then the function returns a new frozen table with only a (to external scripts useless) addres under which the actual table is stored in the local registry table. This returned table also has an assigned metatable which directs all reads to the actual table. On the other hand writing goes through a check which gets the full path of the script that requested the write using debug.info and checks it against the current scripts full name. Writes are only allowed if both match but technically you could allow them in multiple cases of your choosing.

The one flaw with this is as follows: IF THE SCRIPT REQUESTING THE WRITE HAS THE EXACT SAME FULL NAME (path from game instance e.g. “workspace.Folder.Script”) AS THE CURRENT SCRIPT THE WRITE WILL BE ACCEPTED - I am aware of this but for my purposes it isnt worth to overengineer this to get around that very specific case.
Technically, you could rename the script before every debug.info check - so there would be no chance of any other script to be named the same - this does come at the cost of having a very messy script that constantly renames itself (but you could rename it back to the original name after the write is done). I have found out, that debug.info only returns the original full name of the script - this flaw is probably unresolvable.

Some additional metamethods that arent nessescary but make it so for example the length operator (#) works as it should for getting the length of the actual table are:
(these can be added after the __newindex function)

	__iter = function (t : {__addres : number})
		local i = 0
		local actual = _ProtectedTables[t.__addres]
		if #actual > 0 then
			return ipairs(actual)
		else
			return pairs(actual)
		end
	end,
	__len = function (t : {__addres : number})
		return #_ProtectedTables[t.__addres]
	end,
	__tostring = function (t : {__addres : number})
		local actual = _ProtectedTables[t.__addres]
		if #actual > 0 then
			return "{"..table.concat(actual, ", ").."}"
		else
			local str = "{"
			for key, val in actual do
				local val_str
				if type(val) == "string" then
					val_str = `"{val}"`
				else
					val_str = tostring(val)
				end
				str = `{str}["{key}"] = {val_str}, `
			end
			return str:sub(1, -3).."}"
		end
	end,
2 Likes

Storing the address can be finnicky. I’d recommend just storing your table internally and passing the table wrapped in the function:

-->> something like

local function newProtected<T>(source: T): T
	if type(source) ~= "table" then
		error("Not a table")
	end
	
	local nested = {}
	for key, value in source do
		if type(value) == "table" then
			nested[key] = newProtected(value) --> recurse to catch nested tables
		end
	end

	return (setmetatable({}, {
		__index = function(_, key)
			return if nested[key] ~= nil then nested[key] else source[key]
		end,
		__newindex = function(_, key, value)
			error("No permissions")
		end,
		-->> other metamethods
	}):: unknown):: T
end

local yourPrivateTable = {
	apples = "oranges",
	nested = {
		foo = "bar",
	}
}
local wrapper = newProtected(yourPrivateTable)

print(wrapper.apples) --> "oranges"
print(wrapper.nested.foo) --> "bar"

yourPrivateTable.nested.foo = "hello"
print(wrapper.nested.foo) --> "hello"

wrapper.nested.foo = "somethingElse" --> error!

The above solution provides intellisense and doesn’t require complicated path logic (thus fixing the issue you mentioned).

1 Like

Well I wouldnt say storing the adress part is finicky although admittidly using debug.info is finicky, and you do get around doing both. It is a completely different way of doing it, essentially keeping a local table and exposing a new completely read-only table whereas I did it in 1 but I must say it is a better approach since it means there isnt any potentially time consuming processing happening each write.
Honestly I dont know how I didnt think of it, good on you.

But a lot of your code is nesting support which I didnt support at all because I simply dont need it.
(also if you were to add a new nested table to the editable table after you create the “wrapper” it wouldnt be recognized as a new nested table)
Thank you for this second approach though, I will highlight that a better approach can be found in the comments in the post.

1 Like