From 968145d934e2169b2478687bb9d3439925947104 Mon Sep 17 00:00:00 2001 From: Ardi <113623122+hardlyardi@users.noreply.github.com> Date: Thu, 13 Mar 2025 05:16:32 -0500 Subject: [PATCH 1/3] squash --- ...abstract-module-paths-and-init-dot-luau.md | 142 ++++++++++++++++++ docs/method-type-issubtypeof.md | 116 ++++++++++++++ docs/noinfer-type-operator.md | 102 +++++++++++++ docs/types-library-optional.md | 19 +++ 4 files changed, 379 insertions(+) create mode 100644 docs/abstract-module-paths-and-init-dot-luau.md create mode 100644 docs/method-type-issubtypeof.md create mode 100644 docs/noinfer-type-operator.md create mode 100644 docs/types-library-optional.md diff --git a/docs/abstract-module-paths-and-init-dot-luau.md b/docs/abstract-module-paths-and-init-dot-luau.md new file mode 100644 index 00000000..240848cf --- /dev/null +++ b/docs/abstract-module-paths-and-init-dot-luau.md @@ -0,0 +1,142 @@ +# Abstract module paths and `init.luau` + +## Summary + +We will redefine `foo/init.luau` to be the contents of the module named foo for +the purpose of resolving relative `require()` calls within its body. + +We will do this because a significant portion of our users today rely on the +idiom that `init.luau` represents the module's contents, especially when using +Rojo to build for Roblox. + +## Motivation + +First, consider an example filesystem: + +``` +./foo.luau +./package +./package/init.luau +./package/foo.luau +./package/dependency.luau +``` + +Luau maps this filesystem into a module tree. Module paths largely correspond +to filesystem paths, but they are distinct ideas. Given the filesystem above, +Luau considers the following module paths to be valid: + +``` +foo +package +package/init +package/foo +package/dependency +``` + +When Luau is running on a conventional filesystem, we presently offer some +special behaviour to afford the ability for modules to both contain other +modules, and for them to themselves export values and types: The module path +`package` is treated as an alias to `package/init`. + +Library authors can use this feature to afford a package hierarchy with a root +package that exports all of the most important, basic functionality, plus +submodules that either contain implementation details or additional public APIs. + +This feature works well, but it's incompatible with the idiom that Rojo, a +popular tool in our ecosystem today, employs when building artifacts for Roblox. + +This incompatibility stems from how requires are resolved within the body of +`init.luau`. + +Because we consider `package/init` to be an ordinary Luau module, it is +considered to be rooted at `package`. Therefore, a relative +`require('./foo')` call will resolve to `./package/foo.luau` rather than +`./foo.luau`. + +Rojo, by contrast, considers `./package/init.luau` to belong to the containing +directory. A developer using Rojo must instead write +`require('./package/foo')`. This is only necessary when the script is named +`init.luau`. + +Because of this incompatibility, scripts with require-by-string uses in Rojo +projects will not work outside of Roblox places and vice versa. Since +cross-runtime compatibility is an important goal of the require-by-string +project, we would like to rectify this. + +## Design + +First, we introduce a new abstraction: a _module path_. Module paths refer +either to _modules_ or _directories_. + +For the purposes of navigation, both modules and directories are functionally +identical: modules and directories can both have children, which could +themselves be modules or directories, and both types can have at most one +parent, which could also be either a module or a directory. + +The thing that separates a module from a directory is precisely that modules +represent source code that can be imported via the `require()` function. +Directories, by contrast, are merely organizational units. + +The central feature of this RFC is about how module paths correspond to +filesystem paths: A module path refers to a module if it corresponds either to a +`.luau` file or to a directory that contains a file named `init.luau`. A module +path refers to a directory if it refers to a filesystem directory that lacks an +`init.luau` file. + +More concretely, Luau will no longer consider relative requires from a package +`init.luau` file to resolve relative to the script itself. It will instead +resolve relative to the location of the abstract module it represents, i.e. the +location of its parent folder in a filesystem context. + +Secondly, we recognize an unfortunate side effect of this change: code within +`package/init.luau` is forced to write `require('./package/dependency')` when it +specifically wants to carry out the ordinary task of importing a subordinate +module. + +We propose to alleviate this with a special import alias `@self` that resolves +to the path to the current module. + +```lua +-- package/init.luau + +local foo = require('./foo') -- This pulls in the outer foo! +local foo = require('@self/foo') -- import package/foo +``` + +## Drawbacks + +On a filesystem, Rojo's behaviour is frankly very weird! A reader must know the +name of the current source file in order to know what `require('./foo')` will +do, and if a developer renames a file to or from `init.luau`, they will be +forced to rewrite every `require()`. + +We could someday mitigate this with a new lint: If a module is the parent of other +modules, it is poor style for it to directly import sibling or parent modules. +Files named `init.luau` should never issue a require that resolves to a sibling +module. In our example, we would warn if `package/init.luau` were to +`require('./foo')` because that reaches outside of its folder. + +Further, as require-by-string has been live on Roblox for a little while, there +is some risk that changing things now will break existing code. Roblox will use +live telemetry to assess the impact of this change before we move forward. + +## Alternatives + +### Patching Rojo + +Adjusting Rojo to match what Luau does today without breaking any existing +application code is tricky and most likely requires something far weirder than +the change outlined in this RFC. + +Crucially, most code written using Rojo predates string-based requires. All of +this code must continue to run exactly as-is. + +This puts us in quite a bind: + +* If we map `foo/init.luau` to an actual `ModuleScript` at `foo/init` in the + Roblox data model, then instance-based requires break. This rules out clever + solutions like aliasing modules and compatibility shims. +* If we don't, then we need some other magic Roblox rule that changes the + require resolution rules + 1. Only for scripts that, on the Rojo side, are named `init.luau`, and + 2. Only for string-based requires diff --git a/docs/method-type-issubtypeof.md b/docs/method-type-issubtypeof.md new file mode 100644 index 00000000..6b77bc5c --- /dev/null +++ b/docs/method-type-issubtypeof.md @@ -0,0 +1,116 @@ +# type:issubtypeof + +## Summary + +New method on the [`type`](./user-defined-type-functions.md#type-instance) userdata in type functions for performing subtype checks. + +## Motivation + +Currently there is no built in way to do subtype checks in User-Defined Type Functions. This means the developer has to write the following if they wanted to do a subtype check. Subtype checks are useful for type functions, because it makes code like in the following 2 example cases unneeded. + +Case 1: Checking if a type is a string or a string singleton +```luau +local is_string = t:is("string") or (t:is("singleton") and type(t:value()) == "string") +``` + +Case 2: Checking if a type is an enum +```luau +type function isenum(t) + if t:is("union") then + local components = t:components() + local components_that_are_string = 0 + + for _, component in components do + if tv:is("string") or (tv:is("singleton") and type(tv:value()) == "string") then + components_that_are_string += 1 + end + end + return types.singleton( + components_that_are_string == #components + ) + else + return types.singleton(false) + end +end +``` + +Additionally it would be a pain for users to write a type function to be able to do subtype checks for any type. As the following example type function is already very long, despite not covering everything. + +```luau +type function issubtypeof(a, b) + local false_type = types.singleton(false) + local true_type = types.singleton(true) + local is_subtype = false + local a_tag = a.tag + local b_tag = b.tag + + if + (a_tag == b_tag) or + (b_tag == "singleton" and type(b:value()) == a_tag) or + (b_tag == "negation" and type(b:inner()) == a_tag) + then + is_subtype = true + elseif b_tag == "union" or b_tag == "intersection" then + local components = b:components() + local conmponents_that_are_subtype = 0 + + for _, component in components do + if issubtypeof(component):value() then + conmponents_that_are_subtype += 1 + end + end + + return types.singleton(#components == conmponents_that_are_subtype) + elseif b_tag == "table" and a_tag == "table" then + local b_mt = b:metatable() + local a_mt = a:metatable() + + if b_mt == a_mt then + return true_type + elseif b_mt and a_mt then + local b_mts = { b_mt } + local next_b_mt + local next_a_mt = a_mt + + while next_b_mt do + table.insert(b_mts, next_b_mt) + next_b_mt = next_b_mt:metatable() + end + + while next_a_mt do + for _, mt in b_mts do + if mt == next_a_mt then + return true_type + end + end + next_a_mt = next_a_mt:metatable() + end + else + --[[ + For the purposes of this rfc, my point has already been made. And I do not want to write the rest of this type function that nobody should ever use, + as its probably going to be an easy way to hit the type function time limit. + --]] + end + end + + return types.singleton(is_subtype) +end +``` + +## Design + +The [`type`](./user-defined-type-functions.md#type-instance) will gain a new method: `issubtypeof`: + +```luau +type:issubtypeof(arg: type): boolean +``` + +This method will return a boolean indicating if the type is a subtype of `arg`, using luau's existing subtyping algorithm. + +## Drawbacks + +Adds another method to the type userdata in User-Defined type functions. + +## Alternatives + +Do nothing, and let users write the checks themselves as it is currently. diff --git a/docs/noinfer-type-operator.md b/docs/noinfer-type-operator.md new file mode 100644 index 00000000..0c891fb1 --- /dev/null +++ b/docs/noinfer-type-operator.md @@ -0,0 +1,102 @@ +# `noinfer` Type Operator + +## Summary + +This RFC builds on an alternative listed in [#106](https://github.com/luau-lang/rfcs/pull/106). +
This RFC proposes the addition of a new type function, `noinfer`, which could be used to block type inference from various contexts. + +## Motivation + +When a user is working with a function which contains a generic type, they may wish for one or more inputs of the function not to contribute to type inference. E.g., a potential version of `table.insert`: + +```luau +local function insert(tbl: { V }, value: V) + -- ... +end +``` + +Take the following example in the current version of Luau's Type Inference Engine V2: + +```luau +local some_table = { 1, 2, 3 } +insert(some_table, true) +``` + +The above doesn't produce a type error, although it would have in The V1 Type Inference Engine. This is "expected" of Luau's Type Inference Engine V2, but might not be what a user wants. +The purpose of this RFC is to allow users to annotate a type which will not contribute to type inference, such that a polymorphic type's value isn't inferred from an input to a `noinfer` type. + +## Design + +For the purpose of this RFC, additions to Luau's type function runtime will not be considered in the main body. This is because relevant changes to the types runtime or `types` library could be investigated in a future RFC. + +This RFC proposes a solution in the form of a type function, `noinfer`, which will block type inference on its input when a generic type is instantiated implicitly. This RFC allows, i.e., a `greedy_insert` function to be defined: + +```luau +local function greedy_insert(tbl: { V }, value: noinfer) + -- ... +end +local a: "a" +local b: "b" +local some_table = { a, b } +local c: "c" +greedy_insert(some_table, c) -- TypeError: Type '"c"' could not be converted into '"a" | "b"' +``` + +In the above code, `greedy_insert` is instantiated as type `'(tbl: { "a" | "b" }, value: "a" | "b") -> ()'`, which causes a type error. +This would behave similarly in a type alias: + +```luau +type SomeAlias = { + foo: T, + bar: noinfer +} +local function test(input: SomeAlias) + -- ... +end +test({ + foo = "hello", + bar = false +}) -- TypeError: Type 'boolean' could not be converted to 'string' in an invariant context +``` + +If all provided generic inputs are represented by `noinfer` generic type(s), the generic should instantiate with `'unknown'`. + +## Drawbacks + +- Introduces a new keyword in type contexts. +- Introducing a new "modifier" to types in luau could potentially increase the complexity of the type inference engine. +- The behavior proposed here could be 'icky' in the context of some types & type aliases. Consider if a user has an alias with multiple optional properties they wish to belong to identical types. Someone might try: + + ```luau + type Foo = { + foo: T?, + bar: noinfer?, + baz: noinfer? + } + local test: (Foo): T + ``` + + The above code would instantiate the generic with parameter `T` as `'unknown'` if the user tries to pass values `{bar = 123}`, and would not produce an error given the input `{bar = 123, baz = true}`. This RFC does not remove existing behaviour and does not block future solutions to this problem. + +## Alternatives + +### Greedy Annotation for Generic Types + +New syntax could be introduced to allow any inference of a generic type `T` to modify subsequent occurences to follow the behaviour of `noinfer`, expecting a more "precise" type match. This could look something like `greedy T` or `eager T`: + +```luau +local function test(foo: T?, bar: T?, baz: T?) + +end +test("hi", 1) -- TypeError: Type 'number' could not be converted into 'string?' +``` + +Because this error is dependant on the "order" of generic inputs traversed during instantiation, this alternative exposes an implementation detail in a way that provides error messages to the user. However, this alternative could solve some drawbacks of this RFC, while following similar motivation. Such a modifier for generic types isn't *exclusive* to this RFC, and could be investigated at a later date. + +### Do Nothing + +Introduce no new type function or syntax, leave Luau's Type Inference Engine V2 as-is. Due to the reasoning in the motivation section, this doesn't seem desireable. + +### Default Behaviour + +By default, assume similar behavior to the greedy/eager behaviour mentioned before, and add an annotation to allow polymorphic types to be "non-greedy". This defeats a lot of the purpose of bi-directional type inference in Luau's Type Inference Engine V2, as new features predating this RFC would become essentially opt-in. diff --git a/docs/types-library-optional.md b/docs/types-library-optional.md new file mode 100644 index 00000000..7221f179 --- /dev/null +++ b/docs/types-library-optional.md @@ -0,0 +1,19 @@ +# types.optional + +## Summary + +This RFC proposes adding a new method to the `types` library in type functions, that provides a shorthand for `types.unionof(type, types.singleton(nil))`. + +## Motivation + +Optional types occur very commonly, and it is annoying to have to always type `types.unionof(meow, types.singleton(nil))` to make a optional type, this leads to having the following appear in codebases that make use of optional types a lot in type functions: + +```luau +type function optional(type) + return types.unionof(type, types.singleton(nil)) +end +``` + +## Design + +As a solution, a new method to the `types` library should be added called `optional`. When called on a union, this will add `nil` to the components. Otherwise, this new method will act the same as `types.unionof(type, types.singleton(nil))`. From 6801974c3946b80551e982e0cce2394acff5274a Mon Sep 17 00:00:00 2001 From: Ardi <113623122+hardlyardi@users.noreply.github.com> Date: Wed, 7 May 2025 04:03:54 -0500 Subject: [PATCH 2/3] Add alternative: Type Attributes --- docs/noinfer-type-operator.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/noinfer-type-operator.md b/docs/noinfer-type-operator.md index 0c891fb1..61a706f0 100644 --- a/docs/noinfer-type-operator.md +++ b/docs/noinfer-type-operator.md @@ -80,6 +80,10 @@ If all provided generic inputs are represented by `noinfer` generic type(s), the ## Alternatives +### Type Attributes + +Luau could introduce syntax for 'type attributes', consistent with current function attribute syntax. Instead of masking a `noinfer` type as a type function, you could use `@noinfer T` as a type. + ### Greedy Annotation for Generic Types New syntax could be introduced to allow any inference of a generic type `T` to modify subsequent occurences to follow the behaviour of `noinfer`, expecting a more "precise" type match. This could look something like `greedy T` or `eager T`: From b727d1995a84dd5758d129ba8b2e3d3dd6fe34eb Mon Sep 17 00:00:00 2001 From: Ardi <113623122+hardlyardi@users.noreply.github.com> Date: Wed, 7 May 2025 21:30:53 -0500 Subject: [PATCH 3/3] Update noinfer-type-operator.md --- docs/noinfer-type-operator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/noinfer-type-operator.md b/docs/noinfer-type-operator.md index 61a706f0..28cde845 100644 --- a/docs/noinfer-type-operator.md +++ b/docs/noinfer-type-operator.md @@ -82,7 +82,7 @@ If all provided generic inputs are represented by `noinfer` generic type(s), the ### Type Attributes -Luau could introduce syntax for 'type attributes', consistent with current function attribute syntax. Instead of masking a `noinfer` type as a type function, you could use `@noinfer T` as a type. +Luau could allow 'type attributes', consistent with current function attribute syntax. Instead of masking a `noinfer` type as a type function, you could use `@noinfer T` as a type with an attribute. ### Greedy Annotation for Generic Types