How would I do :GetPropertyChangedSignal() for custom oop objects?

Self explanatory. How would I add an signal to detect when an value in an table is changed?

It is a very complicated setup of using proxies with metatables (with typecasting to keep intellisense) to listen to value changes and a custom signal/connection implementation to achieve APIs similar to RBXScriptSignals and RBXScriptConnections.

Here is a real example of these steps together:

Person.lua
--!strict

--[=[
	An example class utilizing custom signal/connection implementations
	and proxies + metatables to listen for changes.
]=]

local ScriptSignal = require(script.ScriptSignal)
local Person = {}

-- define our Person API
type Person = {
	FirstName: string,
	LastName: string,
	Age: number,
	-- custom GetPropertyChangedSignal implementation
	GetPropertyChangedSignal: (self: Person, property: string) -> ScriptSignal.ScriptSignal<>
}

function Person.new(firstName: string, lastName: string, age: number): Person
	return setmetatable(
		-- we create a proxy table to listen for all changes
		{},
		{
			-- custom __index metamethod to access our private class,
			-- or Person if the index is not part of the indexes
			__index = function(self, index)
				local mt = getmetatable(self)
				
				if mt._person[index] ~= nil then
					return mt._person[index]
				end
				
				return Person[index]
			end,
			-- custom __newindex metamethod to listen for changes to
			-- our proxy table, then redirecting it to our private class
			-- to manually fire our custom signal/connection implementation
			__newindex = function(self, index, newValue)
				local mt = getmetatable(self)
				local oldValue = mt._person[index]
				
				if newValue ~= oldValue then
					mt._person[index] = newValue
					
					-- you can also try to save on memory by only creating
					-- custom signals if GetPropertyChangedSignal was called
					if mt._signals[index] ~= nil then
						mt._signals[index]:Fire()
					end
				end
			end,
			_person = {
				FirstName = firstName,
				LastName = lastName,
				Age = age
			},
			_signals = {}
		}
	) :: any
end

function Person:GetPropertyChangedSignal(property: string): ScriptSignal.ScriptSignal<>
	local mt = getmetatable(self)
	
	-- you can also error if the property does not exist
	if mt._person[property] == nil then
		error(`{property} is not a valid property name`, 2)
	end
	
	if mt._signals[property] == nil then
		mt._signals[property] = ScriptSignal.new()
	end
	
	return mt._signals[property]
end

return Person
Example
local Person = require(script.Parent.Person)

local johnDoe = Person.new("John", "Doe", 29)

-- John Doe is 29 years old
print(johnDoe.FirstName, johnDoe.LastName, "is", johnDoe.Age, "years old")

johnDoe:GetPropertyChangedSignal("Age"):Connect(function()
	print(johnDoe.FirstName, johnDoe.LastName, "is now", johnDoe.Age, "years old")
end)

-- John Doe is now 30 years old
johnDoe.Age += 1

You can grab both my custom signal/connection implementation and the example above here:
Person.rbxm (4.0 KB)

2 Likes

I made a guide for this

Very simple once you figure out how __newindex behaves. Metatables are pretty powerful (minus questionable performance overheads in a few places), and maybe with user-defined type funcs we might even be able to make factories that respect strict typing a lot better.

2 Likes

Metatables are the most robust and reusable way to do this.

A simplier (though not automatic) alternative, is a way that’s common in other languages: using getter and setter methods (specifically setter methods). With a setter method, just fire a signal inside the setter method when the property is changed.

For example:

-- Always use the SetSpeed setter method to change the _Speed
function Car:SetSpeed(newSpeed)
    -- (The underscore indicates the property should only be used internally)
    if self._Speed ~= newSpeed then
        -- Assume _SpeedChangedSignal is something like a BindableEvent or Signal object
        self._SpeedChangedSignal:Fire() -- Fires self.SpeedChanged
        self._Speed = newSpeed
    end
end

This can be useful when only a few changed events are needed, though if you need a lot or don’t know which properties the end user will need ones for, using metatables is probably the better approach.

1 Like

I should add, using a proxy object allows you to add better object security, if you want that.

One common pattern in a lot of languages is what I like to call the private modifier, it restricts that property to methods within the class itself, with a proxy set up like this you can sort of restrict access to it.

local Account = {}
local ProxyBindingLayer = setmetatable({}, {__mode = "k"}
Account.__index = Account

local ACCOUNT_GLOB_MT = {
  __index = function(self, k)
    -- Access modifiers can go here
    -- Give methods higher priority than properties
    -- ie: if k == "Balance" then error("property is locked") end

    return Account[k] or ProxyBindingLayer[self][k]
  end,

  __newindex = function(self, k, v)
    -- same logic can go here, ie to enforce strict tables
    ProxyBindingLayer[self][k] = v
    BroadcastChangeSignal(self, k, v)
  end
}

function Account:Widthdraw(balance)
  local realself = ProxyBindingLayer[self]
  -- code to widthdraw balance
end

return function()
  local o = setmetatable({}, ACCOUNT_GLOB_MT)
  ProxyBindingLayer[o] = {
    Balance = 100
  }

  return o
end

The issue with this approach is there’s a LOT of boilerplate, I can wrap it down into functions given that the metatable accesses a shared table, but the issue there is strict typing can be anoying to work with.

2 Likes

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.