Studio Crashes due to the New Type Solver Unable to Infer Complex Type Aliases

I have a module, called Class++, which has a complex type system within itself. It is designed for the New Type Solver and works perfectly well in both Strict and Non-Strict modes on its own. However, when used with another module, such as Signal+, it causes an important problem.

Class++ has a built-in system where it uses a type alias and a type function internally, which grabs the type information from the provided classData, and uses it to construct a class type. Like I mentioned above, this works perfectly well on its own, but the moment that Class++ is provided a type within this classData table that is too complex, the whole Roblox Studio process crashes.

You can reproduce this issue by simply inserting Class++ and Signal+ in a workspace, using the New Type Solver, and by simply entering this code in a Script:

local ClassPP = require(script.Parent.ClassPP)
local Signal = require(script.Parent.Signal)
local class = ClassPP.class

local NSignal = Signal()

local testClass = class "Test" {
	Public = {
		Changed = NSignal
	}
}

Upon pasting/writing this code, Studio hangs immediately and crashes very soon after. I’m not sure why this happens, but I do have some theories and additional bits of information that could help in pinpointing the root of the issue.

Like I mentioned above, Class++ uses an overloaded function that provides arguments to a specific type function in order to create the necessary types. Normally, this should work perfectly fine, but when it has been provided with a complex type, such as a generic type like Signal:

export type Connection = {
	Connected: boolean,
	Disconnect: typeof(
		-- Removes the connection from the signal.
		-- The connection’s data remains.
		function(connection: Connection) end
	)
}

export type Signal<Parameters...> = {
	Connect: typeof(
		-- Connects a function.
		function(signal: Signal<Parameters...>, callback: (Parameters...) -> ()): Connection end
	),
	Once: typeof(
		-- Connects a function, then auto-disconnects after the first call.
		function(signal: Signal<Parameters...>, callback: (Parameters...) -> ()): Connection end
	),
	Wait: typeof(
		-- Yields the calling thread until the next fire.
		function(signal: Signal<Parameters...>): Parameters... end
	),
	
	Fire: typeof(
		--✨
		function(signal: Signal<Parameters...>, ...: Parameters...) end
	),
	
	DisconnectAll: typeof(
		-- Erases all connections.
		-- <em>Much faster than calling <code>Disconnect</code> on each.</em>
		function(signal: Signal<Parameters...>) end
	),
	Destroy: typeof(
		-- Erases all connections and methods.
		-- <strong>To fully erase, also remove all references to the signal.</strong>
		function(signal: Signal<Parameters...>) end
	)
}

It causes a crash. However, by digging and changing things around quite a bit, I have managed to find an interesting detail:

export type Connection = {
	Connected: boolean,
	Disconnect: typeof(
		-- Removes the connection from the signal.
		-- The connection’s data remains.
		function(connection: Connection) end
	)
}

export type Signal<Parameters...> = {
	Connect: typeof(
		-- Connects a function.
		function(signal: Signal<Parameters...>, callback: (Parameters...) -> ()): Connection end
	),
	Once: typeof(
		-- Connects a function, then auto-disconnects after the first call.
		function(signal: Signal<Parameters...>, callback: (Parameters...) -> ()): Connection end
	),
	Wait: typeof(
		-- Yields the calling thread until the next fire.
		function(signal: Signal<Parameters...>): any end -- Changed from Parameters... to any
	),
	
	Fire: typeof(
		--✨
		function(signal: Signal<Parameters...>, ...: any) end -- Changed from Parameters... to any
	),
	
	DisconnectAll: typeof(
		-- Erases all connections.
		-- <em>Much faster than calling <code>Disconnect</code> on each.</em>
		function(signal: Signal<Parameters...>) end
	),
	Destroy: typeof(
		-- Erases all connections and methods.
		-- <strong>To fully erase, also remove all references to the signal.</strong>
		function(signal: Signal<Parameters...>) end
	)
}

This works perfectly fine. It causes no crashes, nor any problems. By changing the Wait and Fire types to no longer use the Parameters generic, the problem gets solved.

Here’s a simple place-file to reproduce the bug:
BugPlace.rbxl (81.0 KB)

Expected behavior

I expect the type solver to be able to infer the types properly without causing a major crash such as this.

A private message is associated with this bug report

1 Like

I have created a simplified version of this issue, within a singular file so you don’t have to deal with the libraries or the linked place.

type ArgF = <T>(arg1: T) -> DefShort<constructClassData<T>>
type DefShort<T> = class<T, T>

export type class<T1, T2> = setmetatable<{
	overload: (accessSpecifier: string, name: string, functionTable: {(...any) -> (...any?)}) -> (),
	static: (accessSpecifier: string, name: string, property: any) -> (),
	read t1: T1,
	read Name: string
}, {
	__index: ({any}, any) -> (any),
	__newindex: ({any}, any, any) -> (),
	__type: string,
	__classtype: string,
	__abstract: boolean,
	__final: boolean
}>

type function constructClassData(tbl: type)
	if not tbl:is("table") then error("Given argument is not a table.") end

	local foundConstructorType = tbl:readproperty(types.singleton("constructor"))
	local constructorType = if foundConstructorType and foundConstructorType:is("function") then foundConstructorType else types.newfunction({ head = {} }, {}, {})

	local foundDestructorType = tbl:readproperty(types.singleton("destructor"))
	local destructorType = if foundDestructorType and foundDestructorType:is("function") then foundDestructorType else types.newfunction({ head = {} }, {}, {})

	local nT = types.newtable({
		[types.singleton("constructor")] = constructorType,
		[types.singleton("destructor")] = destructorType,
		[types.singleton("Public")] = types.newtable({}),
		[types.singleton("Private")] = types.newtable({}),
		[types.singleton("Protected")] = types.newtable({}),
		[types.singleton("Friend")] = types.newtable({})
	})

	nT:setindexer(types.string, types.any)

	for k: type, v in tbl:properties() do
		local key = k:value()
		local value = v.read

		if value and value:is("function") then
			if key ~= "constructor" and key ~= "destructor" then continue end
			nT:setproperty(types.singleton(key), value)
		elseif value and value:is("table") then
			if key ~= "Public" and key ~= "Private" and key ~= "Protected" and key ~= "Friend" then continue end
			nT:setproperty(types.singleton(key), value)
			value:setindexer(types.any, types.any)
		end
	end

	return nT
end

export type Connection = {
	Connected: boolean,
	Disconnect: typeof(
		-- Removes the connection from the signal.
		-- The connection’s data remains.
		function(connection: Connection) end
	)
}

export type Signal<Parameters...> = {
	Connect: typeof(
		-- Connects a function.
		function(signal: Signal<Parameters...>, callback: (Parameters...) -> ()): Connection end
	),
	Once: typeof(
		-- Connects a function, then auto-disconnects after the first call.
		function(signal: Signal<Parameters...>, callback: (Parameters...) -> ()): Connection end
	),
	Wait: typeof(
		-- Yields the calling thread until the next fire.
		function(signal: Signal<Parameters...>): Parameters... end
	),

	Fire: typeof(
		--✨
		function(signal: Signal<Parameters...>, ...: Parameters...) end
	),

	DisconnectAll: typeof(
		-- Erases all connections.
		-- <em>Much faster than calling <code>Disconnect</code> on each.</em>
		function(signal: Signal<Parameters...>) end
	),
	Destroy: typeof(
		-- Erases all connections and methods.
		-- <strong>To fully erase, also remove all references to the signal.</strong>
		function(signal: Signal<Parameters...>) end
	)
}

local FuncA: ArgF
local SignalB: Signal<any>

FuncA({
	Public = {
		Test = nil -- Replace with SignalB
	}
})