Luau type declarations for custom functions and events

Right now there is no way to declare the typing of all of the following APIs:

  • BindableEvent.Event and .Fire
  • BindableFunction.OnInvoke and .Invoke
  • RemoteEvent.OnClientEvent, .OnServerEvent, .FireClient, and .FireServer
  • RemoteFunction.OnClientInvoke, .OnServerInvoke, .InvokeClient, and .InvokeServer

This results in a luau blind spot:

--- on client:
remoteEvent:FireServer('a string')

--- on server:
remoteEvent.OnServerEvent:Connect(function(player:Instance, input:number)
   -- 'input' is the wrong type, but luau doesn't know this
end)

One option is to explicitly declare the types at the instance level. I don’t even know what that would look like and I shudder to think of it. The better option is for luau to keep track of references to these objects and resolve their types based on how the developer uses them, just like regular functions.

10 Likes

input could be anything so you can’t rely on it being a number without testing the type of input (and usually also checking for NaN). I would prefer it to warn for declaring the type to be something other than any for all parameters except for the first player parameter, to avoid accidently relying on the type of the parameters without testing their type (this results in something exploitable quite often, like passing a table when it expected an instance).

Since BindableEvent, BindableFunction, RemoteEvent, and RemoteFunction classes can be used in different scripts, wouldn’t this necessitate looking in other scripts to see how they interact with the objects, even if neither directly reference each other?

1 Like

That’s just true for everything. The point of automatic typechecking is to automatically check this for you.

Yes.

Luau types don’t automatically check the type for you, and probably never will.

--!strict
local function foo(x:number)
	print(x+1)
end
foo("string"::any)
-- in output:
--- ServerScriptService.Script:3: attempt to perform arithmetic (add) on string and number
--- Stack Begin
--- Script 'ServerScriptService.Script', Line 3 - function foo
--- Script 'ServerScriptService.Script', Line 5
--- Stack End
-- types just cause warnings to appear
-- they don't add something to check the type
-- even if the '::any' is removed, the code still works exactly the same
-- but emits a warning

In most cases the type checks are unnecessary, and will come with a significant performance penalty.

We’re not on the same page here. This thread is about script analysis, not runtime type enforcement.

1 Like

I really hate to bump this 3 year old thread but this should really be considered (or at least be addressed as to why this isn’t currently possible/feasible).

One suggestion I may add is adding optional generic types (if it’s possible) to such Instances, or creating an alternative generic types (like what Signal uses).

-- Signal implementation
local mySignal: Signal<string> = Signal.new()
mySignal:Fire(1) -- type-check error

mySignal:Connect(function(v: boolean): ()
    -- type-check error
end)

-- Proposed implementation
local myRemoteEvent: RemoteEvent<string> = Instance.new("RemoteEvent")
local myRemoteEvent: GenericRemoteEvent<string> = Instance.new("RemoteEvent")
myRemoteEvent:Fire(1) -- type-check error

myRemoteEvent.OnServerEvent:Connect(function(player: Player, v: boolean): ()
    -- type-check error
end)

Obviously, it’s not the best solution and inferring the type is probably impossible today, meaning you would have to manually re-define the types, but it’s better than nothing.


Currently, you can do something hacky like the example below, however, it has it’s issues. Although you will see correct autocompletion types, script analysis will not be accurate:

--!strict

-- very lengthy type declarations
export type GenericFireServer<A...> = (self: GenericRemoteEvent<A...>, A...) -> ()
export type GenericFireClient<A...> = (self: GenericRemoteEvent<A...>, Player, A...) -> ()
export type GenericFireAllClients<A...> = (self: GenericRemoteEvent<A...>, A...) -> ()

export type GenericOnServerEvent<A...> = {
	Connect: (self: GenericOnServerEvent<A...>, callbackFunction: (Player, A...) -> ()) -> RBXScriptConnection,
	ConnectParallel: (self: GenericOnServerEvent<A...>, callbackFunction: (Player, A...) -> ()) -> RBXScriptConnection,
	Once: (self: GenericOnServerEvent<A...>, callbackFunction: (Player, A...) -> ()) -> RBXScriptConnection,
	Wait: (self: GenericOnServerEvent<A...>) -> (Player, A...)
}

export type GenericOnClientEvent<A...> = {
	Connect: (self: GenericOnClientEvent<A...>, callbackFunction: (A...) -> ()) -> RBXScriptConnection,
	ConnectParallel: (self: GenericOnClientEvent<A...>, callbackFunction: (A...) -> ()) -> RBXScriptConnection,
	Once: (self: GenericOnClientEvent<A...>, callbackFunction: (A...) -> ()) -> RBXScriptConnection,
	Wait: (self: GenericOnClientEvent<A...>) -> A...
}

export type GenericRemoteEvent<A...> = {
	FireServer: GenericFireServer<A...>,
	FireClient: GenericFireClient<A...>,
	FireAllClients: GenericFireAllClients<A...>,
	OnServerEvent: GenericOnServerEvent<A...>,
	OnClientEvent: GenericOnClientEvent<A...>
} & RemoteEvent

-- you would have to typecast the RemoteEvent as any
local myRemoteEvent: GenericRemoteEvent<string> = Instance.new("RemoteEvent") :: any

myRemoteEvent.OnServerEvent:Connect(function(player: Player, message: string): () 
	-- because of the way types work, "message" is technically a generic of
	-- any (from RemoteEvent) & string (from GenericRemoteEvent<string>).
	
	-- not declaring "message" as a string will give you a generic type.
	-- not only that, since "message" is also any (from RemoteEvent),
	-- Luau will see nothing wrong if it is defined as any other type
end)

-- same issue as above. this is obviously wrong but will accept it as correct:
myRemoteEvent:FireServer(123)

-- everything table-defined in GenericRemoteEvent<A...> and onwards will also
-- have the same behavior of being a generic of any. this is obviously wrong
-- but will accept it as correct:
local rbxScriptSignal: number = myRemoteEvent.OnServerEvent

Sorry to reply so much later, but I agree with the other reply that at a client/server boundary, your code shouldn’t be making assumptions. I think they should be typed unknown and force you to manually assert and refine the types, because otherwise it would encourage writing brittle code that likely has a lot of type safety holes that compromised clients could exploit to break your game.