Strongly Typed Simple Signal Module

I was looking for some signal modules to use in my other OOP classes but none of them had very good typing so I made my own module.

Note: This module does not work with new luau type engine beta feature due to some weird interactions with variadic types.

Please let me know if there are any bugs or memory leaks that I didn’t account for.

Source code:

--!strict
export type SignalPrototype<T...> = {
	Connect: (self: Signal<T...>, callback: (T...) -> ()) -> (Connection<T...>),
	DisconnectAll: (self: Signal<T...>) -> (),
	Fire: (self: Signal<T...>, T...) -> (),
	Destroy: (self: Signal<T...>) -> (),
}
export type SignalInstance<T...> = {
	HeadConnection: Connection<T...>?,
	TailConnection: Connection<T...>?
}
export type Signal<T...> = typeof (setmetatable({} :: SignalInstance<T...>, {__index = {} :: SignalPrototype<T...>})) 

export type ConnectionPrototype<T...> = {
	Disconnect: (self: Connection<T...>) -> (),
}
export type ConnectionInstance<T...> = {
	Signal: Signal<T...>,
	Callback: (T...) -> (), 
	Previous: Connection<T...>?,
	Next: Connection<T...>?
}
export type Connection<T...> = typeof (setmetatable({} :: ConnectionInstance<T...>, {__index = {} :: ConnectionPrototype<T...>}))
	
local Connection = {}
function Connection.new<T...>(signal: Signal<T...>, callback: (T...) -> ()): Connection<T...>
	local self = {} :: ConnectionInstance<T...>
	
	self.Signal = signal
	self.Callback = callback
	self.Previous = nil
	self.Next = nil

	return setmetatable(self, {__index = Connection.prototype})
end

Connection.prototype = {}
function Connection.prototype.Disconnect<T...>(self: Connection<T...>)
	if self.Next then self.Next.Previous = self.Previous end
	if self.Previous then self.Previous.Next = self.Next end
	
	if self.Signal.HeadConnection == self then
		self.Signal.HeadConnection = self.Next
	end
	
	if self.Signal.TailConnection == self then
		self.Signal.TailConnection = self.Previous
	end
	
	self.Next = nil
	self.Previous = nil
	setmetatable(self, nil)
end
	
local Signal = {}

function Signal.new<T...>(): (Signal<T...>)
	local self = {} :: SignalInstance<T...>
	
	self.HeadConnection = nil
	self.TailConnection = nil
	
	return setmetatable(self, {__index = Signal.prototype})
end

Signal.prototype = {}

function Signal.prototype.Connect<T...>(self: Signal<T...>, callback: (T...) -> ()): (Connection<T...>)
	local newConnection = Connection.new(self, callback)
	
	if self.TailConnection then
		self.TailConnection.Next = newConnection
		newConnection.Previous = self.TailConnection
		self.TailConnection = newConnection
	else
		self.HeadConnection = newConnection
		self.TailConnection = newConnection
	end
	
	return newConnection
end

function Signal.prototype.DisconnectAll<T...>(self: Signal<T...>): ()
	local connection = self.HeadConnection
	while connection do
		local next = connection.Next
		connection:Disconnect()
		connection = next
	end
	
	self.HeadConnection = nil
	self.TailConnection = nil
end

function Signal.prototype.Fire<T...>(self: Signal<T...>, ...: T...): ()
	local connection = self.HeadConnection
	while connection do
		connection.Callback(...)
		connection = connection.Next
	end
end

function Signal.prototype.Destroy<T...>(self: Signal<T...>): ()
	self:DisconnectAll()
	setmetatable(self, nil)
end

return Signal
1 Like

It’s almost always a bad idea to use typeof. You can define the classes directly. it’s clearer and easier to maintain:

export type Connection<T...> = {
	Disconnect: (self: Connection<T...>) -> (),
	_signal: Signal<T...>,
	_callback: (T...) -> (),
	_prev: Connection<T...>?,
	_next: Connection<T...>?
}
export type Signal<T...> = {
	Connect: (self: Signal<T...>, callback: (T...) -> ()) -> Connection<T...>,
	DisconnectAll: (self: Signal<T...>) -> (),
	Fire: (self: Signal<T...>, T...) -> (),
	Destroy: (self: Signal<T...>) -> (),
	_head: Connection<T...>?,
	_tail: Connection<T...>?
}

Is there a specific reason as to why it is a bad idea?

It’s not bad per se, but it can produce strange large types from inference that are hard to work with.

In your case, it’s unavoidable because typeof is the only way to get a table type with a metatable in the old solver. In the new solver, however, you might be able to use a type function like setmetatable<SignalInstance<T...>, {__index: SignalPrototype<T...>}>.

When you use typeof, you lose control over your data. It’s like a black box that makes everything work, but without being sure why.

Also, another big advantage of type systems is that they let you describe your code precisely. For example, in your case, you can know exactly what the shape of Signal and Connection is, and at the same time, the type checker can do its job. When using typeof, you lose that.

Of course, there are legitimate use cases for using typeof, like dealing with third-party libraries, legacy code, or when you just want things to work.

What are you talking about?
He uses it to grab metatable results.
You don’t have any of those properties; they are essentially stored under __index callback.
You don’t know what you are talking about.

@WaffIe_Cake keep doing what you were doing
Those suggestors don’t know what they are talking about.

That’s obvious. But you’re not saying why this way is better or why my suggestion is worse. I’m basing my reasoning on POLA (Principle of Least Astonishment), which expects a system’s behavior to be what a reasonable developer would anticipate. There shouldn’t be any “hidden magic.”

Again, why? These things don’t help anyone. Please explain yourself.

FWIW, using typeof with metatables is the standard way to type OOP currently. The new solver adds a setmetatable type function, which makes this a bit cleaner, but that’s still in beta:

export type Signal<T...> = setmetatable<SignalInstance<T...>, {__index = {} :: SignalPrototype<T...>}>

minor fix to type declaration

export type Signal<T...> = setmetatable<SignalInstance<T...>, {__index: SignalPrototype<T...>}>
1 Like

You still don’t know what you’re talking about.
And please stop tossing around buzzwords you clearly don’t understand - it doesn’t make you look smart, it just makes your argument weaker.
A table doesn’t have those values in the first place, so your point is just wrong.

Very true. But there are still ways to write more explicit code:

--!strict

export type Connection<T...> = {

	Disconnect: (self: Connection<T...>) -> (),

	_signal: Signal<T...>,
	_callback: (T...) -> (),
	_prev: Connection<T...>?,
	_next: Connection<T...>?
}

export type Signal<T...> = {
	Connect: (self: Signal<T...>, callback: (T...) -> ()) -> Connection<T...>,
	DisconnectAll: (self: Signal<T...>) -> (),
	Fire: (self: Signal<T...>, T...) -> (),
	Destroy: (self: Signal<T...>) -> (),

	_head: Connection<T...>?,
	_tail: Connection<T...>?
}

local Connection = {}
Connection.__index = Connection

function Connection.new<T...>(signal: Signal<T...>, callback: (T...) -> ()): Connection<T...>
	local self = setmetatable({} :: any, Connection) :: Connection<T...>
	self._signal = signal
	self._callback = callback
	self._prev = nil
	self._next = nil
	return self 
end

function Connection.Disconnect<T...>(self: Connection<T...>)
	if self._next then self._next._prev = self._prev end
	if self._prev then self._prev._next = self._next end

	if self._signal._head == self then
		self._signal._head = self._next
	end

	if self._signal._tail == self then
		self._signal._tail = self._prev
	end

	self._next = nil
	self._prev = nil
	setmetatable(self, nil)
end

local Signal = {}
Signal.__index = Signal

function Signal.new<T...>(): Signal<T...>
	local self = setmetatable({} :: any, Signal) :: Signal<T...>
	self._head = nil
	self._tail = nil
	return self
end

function Signal.Connect<T...>(self: Signal<T...>, callback: (T...) -> ()): Connection<T...>
	local newConnection = Connection.new(self, callback)

	if self._tail then
		self._tail._next = newConnection
		newConnection._prev = self._tail
		self._tail = newConnection
	else
		self._head = newConnection
		self._tail = newConnection
	end

	return newConnection
end

function Signal.DisconnectAll<T...>(self: Signal<T...>)
	local connection = self._head
	while connection do
		local next = connection._next
		connection:Disconnect()
		connection = next
	end
	self._head = nil
	self._tail = nil
end

function Signal.Fire<T...>(self: Signal<T...>, ...: T...)
	local connection = self._head
	while connection do
		connection._callback(...)
		connection = connection._next
	end
end

function Signal.Destroy<T...>(self: Signal<T...>)
	self:DisconnectAll()
	setmetatable(self, nil)
end

return Signal

It seems like you can’t explain why.

Becouse i did already, and yet you keep throwing nonsense and deflecting.
Table does not contain methods dirrectly.
It will be a super devastating in the future once typecheck will affect bytecode.
You should use either type function setmetatable<> or typeof(setmetatable({},{})) shenanigan

When that day comes, we’ll just update the code :+1:

1 Like

When that day comes we are gonna be dead by then :rofl:
Unless you manage to live like 100 years from now (idk deadlines of roblox staff)

1 Like