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,