Rules to reduce type complexity
I love type safety, and I make a lot of packages that are type safe. Like many type safety lovers on Roblox I've found myself in a bind - I want my types to be perfect, but the more accurate they get, the more complex they get.
Eventually, if they use enough type-safe modules, every dev runs into this error in the header of a file:
TypeError: Internal error: Code is too complex to typecheck! Consider adding type annotations around this areaLuau[1015](https://luau-lang.org/typecheck)
Nothing is more painful. It usually requires either messing with your FFlags, or going through a perfectly typed system and replacing a bunch of return types with any. Later on when you then encounter a type error from those functions that would have otherwise been detected, you curse the day you didn't get into game dev through a C++ based engine like everyone else.
I do a lot of package development for my workflow, especially low-level stuff. In the relatively smaller project scope, my perfectly accurate typing desires can be fulfilled. However when that package is then installed and the custom class used a hundred times in a module, the dev using it is often robbed of those type benefits by complexity errors. Because these are packages, I can't just tell people to modify their FFlags - things have to work out of the box.
As a result, I find myself frequently hacking together shell scripts to scrape and replace types in my code at export to keep the complexity low. This is messy and slow, I would much rather use darklua with some established rules.
proposals
For the prior examples, we are going to be processing this class type I'm developing for my rust inspired reqwest package:
type ResponseStruct<V> = {
_url: Url,
_body: Body<V>,
_statusCode: number,
_statusMessage: string,
_headers: HeaderMap,
}
type ResponseImpl<V> = {
__index: ResponseImpl<V>,
__tostring: (self: Response<V>) -> string,
text: (self: Response<V>) -> Result<string & V, DecodingError>,
json: (self: Response<V>) -> Result<Json & V, DecodingError>,
form: (self: Response<V>) -> Result<Form & V, DecodingError>,
bytes: (self: Response<V>) -> Result<buffer, DecodingError>,
ok: (self: Response<V>) -> boolean,
headers: (self: Response<V>) -> HeaderMap,
contentLength: (self: Response<V>) -> Option<number>,
status: (self: Response<V>) -> number,
statusMessage: (self: Response<V>) -> string,
url: (self: Response<V>) -> Url,
builder: (self: Response<V>) -> ResponseBuilder<V>,
}
export type Response<V> = typeof(setmetatable({} :: ResponseStruct<V>, {} :: ResponseImpl<V>))
self_to_any
99% of the time when self is the parameter, I'm calling it with obj:Method() not Class.Method(obj) - so the type safety benefits are marginal.
type ResponseStruct<V> = {
_url: Url,
_body: Body<V>,
_statusCode: number,
_statusMessage: string,
_headers: HeaderMap,
}
type ResponseImpl<V> = {
__index: ResponseImpl<V>,
__tostring: (self: any) -> string,
text: (self: any) -> Result<string & V, DecodingError>,
json: (self: any) -> Result<Json & V, DecodingError>,
form: (self: any) -> Result<Form & V, DecodingError>,
bytes: (self: any) -> Result<buffer, DecodingError>,
ok: (self: any) -> boolean,
headers: (self: any) -> HeaderMap,
contentLength: (self: any) -> Option<number>,
status: (self: any) -> number,
statusMessage: (self: any) -> string,
url: (self: any) -> Url,
builder: (self: any) -> ResponseBuilder<V>,
}
export type Response<V> = typeof(setmetatable({} :: ResponseStruct<V>, {} :: ResponseImpl<V>))
metatable_to_intersection
Currently the official luau recommended way to explicitly type faux-objects is to use typeof(setmetatable({} :: struct, {} :: impl)), which in my experience causes the type engine to chug. However, basic intersectioning (&) is much more performant.
As a result I often do this:
type ResponseStruct<V> = {
_url: Url,
_body: Body<V>,
_statusCode: number,
_statusMessage: string,
_headers: HeaderMap,
}
type ResponseImpl<V> = {
text: (self: Response<V>) -> Result<string & V, DecodingError>,
json: (self: Response<V>) -> Result<Json & V, DecodingError>,
form: (self: Response<V>) -> Result<Form & V, DecodingError>,
bytes: (self: Response<V>) -> Result<buffer, DecodingError>,
ok: (self: Response<V>) -> boolean,
headers: (self: Response<V>) -> HeaderMap,
contentLength: (self: Response<V>) -> Option<number>,
status: (self: Response<V>) -> number,
statusMessage: (self: Response<V>) -> string,
url: (self: Response<V>) -> Url,
builder: (self: Response<V>) -> ResponseBuilder<V>,
}
export type Response<V> = ResponseStruct<V> & ResponseImpl<V>
Note the removal of metamethods as they will show up as available methods otherwise.
remove_private_fields
The agreed upon standard for private table fields in a language without official support for them is to prefix them with "_". I recommend filtering them if they're not expected to be used externally. To be honest this might also just be a good thing to support to prevent the type engine from giving anyone any funny ideas on indexing a private field of a package.
type ResponseStruct<V> = {}
type ResponseImpl<V> = {
__index: ResponseImpl<V>,
__tostring: (self: Response<V>) -> string,
text: (self: Response<V>) -> Result<string & V, DecodingError>,
json: (self: Response<V>) -> Result<Json & V, DecodingError>,
form: (self: Response<V>) -> Result<Form & V, DecodingError>,
bytes: (self: Response<V>) -> Result<buffer, DecodingError>,
ok: (self: Response<V>) -> boolean,
headers: (self: Response<V>) -> HeaderMap,
contentLength: (self: Response<V>) -> Option<number>,
status: (self: Response<V>) -> number,
statusMessage: (self: Response<V>) -> string,
url: (self: Response<V>) -> Url,
builder: (self: Response<V>) -> ResponseBuilder<V>,
}
export type Response<V> = typeof(setmetatable({} :: ResponseStruct<V>, {} :: ResponseImpl<V>))
My personal recommendation is to exclude metamethod double underscore prefixes from this rule due to the type engine emphasizing them directly at times.
string_literal_to_string
Basically, go from this:
export type Method = "GET" | "POST" | "PUT" | "DELETE" | "OPTIONS" | "PATCH"
to this:
export type Method = string
This avoids a lot of churning from the type engine, especially when these string literal unions are erased so frequently by the engine itself (at least until the new one is ready).
conclusion
Type safety is cool, but the type engine is slow and will tap out quite frequently. As a result, if we value type safety we need to be able to build packages that reshape with these complexity limitations in mind. Adding these rules would help a ton with this.
I have some rust experience, I'd be happy to try and assemble a pull request for these features - I'd just want to confirm the design with the team before I put in the effort.
Thank you for reading!