Skip to content

Mutable before return Export-by-Value Semantics#179

Open
MagmaBurnsV wants to merge 4 commits intoluau-lang:masterfrom
MagmaBurnsV:Alternative-Exports-RFC
Open

Mutable before return Export-by-Value Semantics#179
MagmaBurnsV wants to merge 4 commits intoluau-lang:masterfrom
MagmaBurnsV:Alternative-Exports-RFC

Conversation

@MagmaBurnsV
Copy link
Copy Markdown
Contributor

@MagmaBurnsV MagmaBurnsV commented Feb 25, 2026

This proposes alternative semantics for #42, which treats exports as syntax sugar for assigning keys to an export table before sealing.

This allows reassigning exported values before the module is returned, solving the original RFC's issues with mutually dependent functions and shorthand aliasing. Furthermore, it introduces export semantics that are already representable in the language, dismissing the need for introducing const-ness to the language.

Rendered

@MagmaBurnsV MagmaBurnsV marked this pull request as ready for review February 25, 2026 01:08
@Cooldude2606
Copy link
Copy Markdown

The following cases are currently unclear in their behaviour, root causing being a lack of definition for the interaction of export statements with existing variables. I.e. you currently only explain that two exports cannot share an identifier.

-- This is mentioned in alternatives, but not in body
local function foo() end
export foo -- syntax error?

local bar = 0
export bar = 1 -- syntax error?
print(bar) -- 0 or 1?

fruit = "apple" -- global
export fruit -- shadowing or reference?
fruit = "pear" -- is fruit now local?
print(fruit) -- pear?
print(_G.fruit) -- apple?

export animal
animal = "dog"
local animal = "cat" -- syntax error?
print(animal) -- cat or dog?

@MagmaBurnsV
Copy link
Copy Markdown
Contributor Author

MagmaBurnsV commented Feb 26, 2026

I've clarified that the restriction only applies to exported variables, and exports follow conventional shadowing rules in regard to non-exported variables, so your example would desugar into:

local _EXP = {}

local function foo() end
_EXP.foo = nil

local bar = 0
_EXP.bar = 1
print(_EXP.bar) -- 1

fruit = "apple"
_EXP.fruit = nil
_EXP.fruit = "pear"
print(_EXP.fruit) -- pear
print(_G.fruit) -- apple

_EXP.animal = nil
_EXP.animal = "dog"
local animal = "cat"
print(animal) -- cat

-- {foo = nil, bar = 1, fruit = "pear", animal = "dog"}
return table.freeze(_EXP)

Or alternatively if optimized into locals by the compiler:

local function foo() end
local foo

local bar = 0
local bar = 1
print(bar)

fruit = "apple"
local fruit
fruit = "pear"
print(fruit)
print(_G.fruit)

local _animal = nil
_animal = "dog"
local animal = "cat"
print(animal)

return table.freeze({foo = foo, bar = bar, fruit = fruit, animal = _animal})

(Uninitialized exports looking like shorthand exports may be a good reason not to support them. The only reason I allowed them in the design was to obtain parity with how the local keyword works, but I am fine with removing them if that's what the team wants.)

@Cooldude2606
Copy link
Copy Markdown

Cooldude2606 commented Feb 26, 2026

Uninitialized exports looking like shorthand exports may be a good reason not to support them.

While this is true, it should be really easy to detect most cases through linting because export follows existing shadowing rules. In fact the same lint would catch another common error which could result from mistypes or copy paste errors.

I would propose "Uninitiated export is never assigned to" as a lint rule.
This also mirrors common lint rules such as unussed local variable, or variable is never accessed.

local function foo() end
export foo -- Uninitiated export is never assigned to
-- The above also catches the incorrect assumption of exporting locals
-- A common lint rule "no shadowing" would also catch this error

export bar -- No lint because bar is used in an assignment
if condition then
    bar = function() end
end

export f, g -- No lint because f and g is used in an assignment
function f() g() end
function g() f() end

export user -- Uninitiated export is never assigned to
User = {

}

export fruit = nil -- Uninitiated export is never assigned to
-- Although assigned nil explictly, there would never be a reason to export nil

Lint rules are not necessarily part of the langauge, but the possibilty of it being easily lintable helps to remove confussion for users and maintains feature parity with the local keyword as you described.

@SPY SPY changed the title Alternate Export-by-Value Semantics Mutable before return Export-by-Value Semantics Apr 15, 2026
@SPY
Copy link
Copy Markdown

SPY commented Apr 15, 2026

We discussed internally different approaches resolving mutual recursive functions and related issues. I think we all agreed mutable-before-return semantic will be least evil here. I intend to merge this RFC at the end of the week if no strong evidence against it will be provided.

Comment thread docs/export-keyword.md Outdated
Exported variables will count towards the local variable limit, as they can be optimized into locals by the compiler.

```luau
export version = "5.1"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Also we can think about export const version = "5.1" syntax to make assignment to version a syntax error(or even make it default). In this case export local x can be symmetric syntax explicitly signaling x will be modified later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Honestly, since I made this RFC with the expectation that const would be rolled back (because the whole rationale for const was to fill the "can't reassign" gap immutable exports brought), I never had any plans for introducing const-ness to exported tokens. Now that const is here but immutable exports aren't, things are awkward.

I guess I can try to retrofit everything with const in mind, but my fear is the result is going to be a little "sore" since the features here are going to added in backwards.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I guess the only rational way to do this is to make export a keyword that goes before declarations like you've suggested. Functions/classes might have to be special-cased where they are always exported const, but that should be fine.

Comment thread docs/export-keyword.md
local function foo(x) return x * 2 end
export foo -- exports nil, shadows foo
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One additional drawback is, in large Luau files, reassignable exports can introduce a potential footgun. That is the tradeoff to enable cleaner mutual recursion, but I think it should be mentioned at least.

Comment thread docs/export-keyword.md Outdated
return table.freeze({tau = tau})
```

Furthermore, static exports that are never reassigned to are capable of being inlined and constant folded across modules, assuming future support for static imports.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The "staticness" of the export is not required for potential optimizations. Because export statements are top-level statements, we can determine it's final exported value at compile time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I guess a more correct way to phrase this would be "first-class exports allow for the compiler to assume exported tokens cannot be reassigned after module return, unlocking the ability to bring constant-folding and inlining across module boundaries."

Changes syntax in lieu of const with different implications for drawbacks/alternatives.
Comment thread docs/export-keyword.md
## Design
The `export` contextual keyword will now be allowed anywhere before variable and function declarations at the top level of a module, including `local`, `const` and `function` declarations.

Exporting declarations that are in nested `do end` blocks under the top scope are also permitted. Attempting to export a declaration outside the module scope will result in a parse error.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can I have an example where you would want to hide exported values from the rest of the module they are defined in? I'm struggling to see why this would be desired behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The first codeblock in the doc shows an example where an exported function references a scoped upvalue for encapsulation.

do
	local counter = 0
	
	export function increment(): number
		counter += 1
		return counter
	end
end
-- counter and increment not visible here

It's less about hiding the exported bindings and more about hiding their dependencies.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hiding the dependencies makes sense, however, I do think it will be nicely covered by the classes RFC, plus any additional RFC extensions to add a private keyword.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants