Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions docs/support-for-cyclic-requires.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
# Support Cyclic Imports

## Summary

Now that Luau is adding classes to the language, it's much more important that we afford some way modules to cyclically import one another.

This RFC proposes that `require()` be augmented to pass an export table into the module. This allows the runtime to close the loop and allow many cyclic import scenarios to work as desired.

## Motivation

Luau has always restricted `require()` cycles. If the runtime encounters a cycle while evaluating a require, it raises an error and stops attempting to load the code.

Prior to our addition of classes as a builtin language feature, this was rarely a big deal because it was always possible to move functions and type definitions into different source files to break any cycles that might arise. Luau also permits `require()` to be called within a function body.

This problem becomes much more difficult to deal with when classes are added to the mix because classes are always defined at the top level and must always be entirely defined within a single module.

Without cyclic requires, the following program cannot be evaluated.

```luau
-- A.luau

local B = require("./B")

class A
public children: {B.B}

function add_child(self)
table.insert(self.children, B.B {})
end
end

-- B.luau

local A = require("./A")

class B
public parent: A.A
end
```

The developer is left to choose between two bad options:

1. They could introduce extra modules that just define interface types for `A` and `B`, or
2. Move both classes into the same script

Option 1 is laborious and sacrifices the fidelity of the type system. Option 2 potentially means that the developer's entire program must be specified in a single script\!

## Runtime Design

For modules that return tables, we can solve this issue by having `require` tie the knot: When it encounters a cyclic import, `require` will instead return an empty table that will later be populated with the export surface of the module. As long as the requesting module doesn't access it at the topmost global scope, that table will eventually be populated and everything will work out. The system will temporarily attach a metatable to surface these issues and produce a clear error message.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may not be following all the details here correctly, but would it be problematic if the ModuleScript tries to set a metatable on the export table? i.e. would it clobber the metatable that was already set by the system?


There are subtle edge cases to consider here:

1. If a module fails to access a property from another module because of a cycle, Luau needs to clearly communicate what happened.
2. If a module acquires a reference to an incomplete module due to a cycle, it should not be able to mutate that module\!
3. Today, many modules return something other than a table. It is okay if these modules do not support participation in cycles, but they still need to work as-written.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you provide more explanation as to why this is ok?
Is it the case that these types of modules don't need to support cycles at all, or that we just consider it a corner case that isn't important enough to handle?

4. This proposal requires modules to be adjusted to work with cyclic imports. Modules that have not been adjusted need to work exactly as-written.
5. When a non cycle-supporting module appears in a cycle, Luau still needs to communicate the problem to developers clearly.

### Algorithm

`require()` will be adjusted to do the following:

1. First, augment the module of the current script's export table with a new `CyclicDependencyError` metatable. This metatable prohibits reads and writes to the table by raising an exception with a clear error message.
2. Look up the requested module in the cache to see if it has already been loaded or begun loading
3. If a module is in the cache, return it immediately. Otherwise,
4. Populate the cache with an empty table.
Comment on lines +65 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be better to indicate this is really one step with two alternatives.

5. Pass this new table to the target script as its sole argument and evaluate it. This table can be accessed within the script via `...` at the top level.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any backwards compatibility issues here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how would hot-reloading a module affect everything, since it talks about cache

6. Once the module has been evaluated and returned a value, test to see if that value is the same as the table that was passed in. If they are not the same, set `CyclicDependencyError` as the metatable on the original export table. (the one that wound up not being used) The table will also be frozen for good measure.
7. Replace the module cache result with the result of the module
8. Strip `CyclicDependencyError` from the current script's export table.

This approach handles many cases, but has an important limitation: A module that participates in a cycle can freely access imported symbols within function bodies, but not at the top level. This is because those imported symbols cannot be guaranteed to have been evaluated yet.

Step 6 covers an important edge case: In this design, the `require` function sometimes speculatively returns a table with the expectation that it will eventually become the export surface of the requested module. If it is not, then we have a problem: We have already provided that table to other requesting modules\! Luckily, this can only happen when we encounter a cycle between modules that do not accept the export table, so all we need to do is to mark that speculative export table as something that cannot be used.

The new metatable `CyclicDependencyError` can roughly be defined as follows:

```luau
local CyclicDependencyError = {
__index = function(self, prop)
error(`Cannot access the exported field {prop} because it has a cyclic dependency on its requiring module`)
end,
__newindex = function(self, prop, value)
error(`Cannot set the exported field {prop} because it has a cyclic dependency on its requiring module`)
end
}
```

In the absence of `export`, a script must be updated to support cyclic requires by making a small edit: Instead of creating an export table directly with `{}`, the script should accept it from `...` like so:

```luau
local exports = ...
Copy link
Copy Markdown
Contributor

@karl-police karl-police May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, this is GETVARARGS. Now, the thing I know about it, is that it's often used within function bodies.

Doing local var = ... would mean that it's taking 1 element from the var arg alone.

If I do local a,b,c = ... it would take 3 and not more.

What would the design have to change here? Or what would this syntax mean if it is within function bodies?
Or does nothing change about GETVARARGS and it will stay 1 element, it's just to accept the export table


function exports.foo() end
exports.MY_CONSTANT = true

return exports
```

If necessary, the script could instead adopt a compatibility shim so that it works in older Luau environments that do not implement this RFC: `local exports = ... or {}`

The new `export` keyword will be updated to handle this automatically.

This algorithm satisfies a bunch of important properties:

While existing code will not support cyclic `require()` calls, it will continue to work as-written. Modules that return non-table values will also continue to work exactly as expected.

If necessary, a module could be crafted to work with or without support for cycles by instead starting with `local exports = ... or {}`.

### Examples

#### Reentrant Accesses

```luau
--- A.luau

local B = require("B")

export class Tree
children: {B.Node}

function append(self, prototype: B.Node)
-- In this example, we suppose that the tree needs to
-- insert a clone of the passed argument.
table.insert(self.children, B.Node(prototype))
end
end

--- B.luau

local A = require("A")

-- create a global tree for some reason
local t = A.Tree{children={}}

export class Node

end

-- main.luau

require("A")
```

The order of operations in this program is:

1. `main.luau` starts importing `A.luau`
2. `A.luau` starts importing `B.luau`
3. `B.luau` attempts to import `A.luau`. We sense the cycle and short circuit; the incomplete module `A` is returned immediately.
4. `B.luau` attempts to access `A.Tree`. The value `A` is still incomplete and therefore has the `CyclicDependencyError` metatable attached to it. We tell the developer that a cyclic dependency error has been encountered and raise an exception. The developer can use the stack trace to understand the cycle.

#### Improper Reentrant Mutation

```luau
--- A.luau

local B = require("B")

B.foo = "bar"

--- B.luau

local A = require("A")

export const foo = "foo"

--- main.luau

require("B")
```

If we naively execute our planned resolution order, things proceed as follows:

1. `main.luau` starts evaluating `require("B")`
2. `B.luau` starts evaluating, but is immediately blocked on `require("A")`
3. `A.luau` evaluates `require("B")`, which immediately returns with an empty table from the module cache
4. `A.luau` inserts a property into the export table of `B`\!
5. `B.luau` resumes execution with an unexpected extra entry in its export table

`CyclicDependencyError` saves us here. We use it to freeze the shape of `B` at step 2\. It remains frozen until step 5\. We therefore raise an error in step 4\.

## Type System Design

The user-facing behaviour of the type inference engine should be unchanged as a result of this RFC, but the internal structure of the type checker is going to need significant changes.

Today, typechecking is driven by a class called `Frontend`. It accepts a set of modules that need checking, builds a DAG from that, and checks modules one after another.

We will augment this class to instead work on one strongly-connected component\* at a time. All modules within an SCC use the same arena and are typechecked together in a single pass through the solver.
Copy link
Copy Markdown
Contributor

@alexmccord alexmccord May 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could also keep them separate, and instead dispatch one constraint solver in the same component, and dispatch the next one if that one is stuck, and so on, until either all of them completes or all of them are stuck.

This would avoid a constraint from module A making its way into some random metavariable's bounds in module B.


\* A "strongly connected component" is a set of modules that all mutually `require()` one another.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wording nit: if I didn't know what an SCC was (or forgot because it's been a bit since my graph theory class), I might assume this only meant direct mutually recursive requires, e.g.:

--- A.luau
local B = require('B')
--- B.luau
local A = require('A')

... and not just any set of modules where every one depends on every other via some chain of requires.


A problem that a developer might run into is that, if their application consists of a very large SCC (their whole application, perhaps\!), their incremental typechecking performance will be very bad: Luau will have to recheck all files whenever any file in the SCC has changed.

To mitigate this and put some soft pressure on the developer, we'll report a warning when we encounter an SCC that consists of too many modules. This warning will explain that large clusters of cyclic modules can cause typechecking performance to degrade badly. We'll allow this limit to be configured via `FrontendOptions`.

We need to take particular care not to break the old type solver. We will probably need to write some extra logic to ensure that it continues to handle cyclic imports exactly as it does today.

## Drawbacks

The restrictions on how cyclic imports can be used are subtle\! If two mutually-recursive modules need access to one another at the top level, the code will fail to load.

For instance, the following code will fail:

```luau
--- A.luau

local B = require("B")

class ClassAOne extends B.ClassOne ... end
class ClassATwo ... end

--- B.luau

local A = require("A")

class ClassBOne ... end
class ClassBTwo extends A.ClassATwo ... end
```
Comment on lines +203 to +219
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Food for thought, I think this would get tricker with a (sketched out) import syntax:

--- A.luau

import ClassBOne from B

export class ClassAOne extends ClassBOne ... end
export class ClassATwo ... end

--- B.luau

import ClassATwo from A

class ClassBOne ... end
class ClassBTwo extends ClassATwo ... end

... if we're considering this a serious drawback, this seems like the real danger.


With the described design, we will produce a sensible error, but the restriction itself is fairly complicated and is likely to confuse users. They will likely have to think a little bit about how to adjust the design of their code.