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
165 changes: 165 additions & 0 deletions docs/export-keyword.md
Copy link
Contributor

@alexmccord alexmccord Jun 12, 2024

Choose a reason for hiding this comment

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

One thing I noticed not mentioned here is how this would work with two mutually dependent exported functions. If you replace export with local in this snippet, it doesn't do what you want it to do.

export function f()
  g()
end

export function g()
  f()
end

Copy link
Member

Choose a reason for hiding this comment

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

I think my personal expectation here is that this is going to behave the same way as locals, and we can perhaps provide a plain export f export g (though I suspect syntactic ambiguity will be an issue here) to let you export a global.

I would be open to hearing reasons why it should go the other way instead and behave like globals, but I think regardless of the choice, we don't want to introduce a new third semantics here, export should behave like one of them.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agree with @aatxe on this one.
If we want to support the described behavior I think we should look into hoisting but that would be a separate RFC.

Copy link
Contributor

Choose a reason for hiding this comment

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

Given that export probably only makes sense at module level, it stands to reason they should work like globals.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've adopted the local semantics since they seem like the best place to start, with the option to forward-declare using const or local. In the future we may consider hoisting functions on the form <keyword> function <ident>()

Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Export Keyword

## Summary

Extend the `export` keyword to support values and functions.

## Motivation

Today, it is possible to export a type from a module with the `export` keyword:

```luau
export type Point = { x: number, y: number }
```

However, this mechanism is currently limited to types. Extending `export` to values and functions would provide a consistent way to expose a stable API from a module, while avoiding the extra boilerplate of returning a table.

## Design

### Syntax

Allow `export` in declarations anywhere that defines a name (`local`s, `function`s, and `type`s), but only at the top-level of a module:

```luau
export version = "5.1"

export function init()
-- do a thing
end

export type Point = { x: number, y: number }
```

`export` for values is not permitted inside function bodies or other non–top-level scopes. Doing so results in a parse error.
Copy link
Contributor

Choose a reason for hiding this comment

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

I believe you should still be able to export values from within do end blocks, as this could allow for neat namespacing with lexical scoping:

do
	local count = 0
	
	export function increment(): number
		count += 1
		return count
	end
end

-- count and increment not visible here

Copy link
Contributor

@alexmccord alexmccord Feb 8, 2026

Choose a reason for hiding this comment

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

This is what local modules does, if we had some sort of module keyword.

module M is
  local count = 0

  export function increment(): number
    count += 1
    return count
  end
end

export increment = M.increment

Which is equivalent to a closure, modulo (heh) namespacing/qualified paths/etc.

local function makeM(): () -> number
  local count = 0

  return function() -- ignoring the _actual_ exporting machinery as red herring
    count += 1
    return count
  end
end

export increment = makeM()

You could even instantiate it multiple times. Module functors.

module Make(n: number)
  local count = 0
  
  export function next(): number
    count += n
    return count
  end
end

module Counter = Make(1)

export increment = Counter.next
export incrementX = makeM() -- has its own independent `count` state
export incrementY = makeM() -- and its own independent `count` state


### Semantics

#### Const By-Default
Copy link
Contributor

Choose a reason for hiding this comment

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

Alternative framing: this is not necessarily a const binding.

export foo = 7
print(foo)
foo = 8

After elaboration, all occurrences of foo are a field projection of the export table that you cannot utter in the surface syntax:

local mod = {}
mod.foo = 7
table.freeze(mod)

print(mod.foo)
mod.foo = 8

And since a field projection foo inherits the permissions of the projected-from lvalue mod which is frozen, foo cannot be reassigned. This avoids talking about const bindings and everything is still framed around the pre-existing mechanism of frozen tables.

Copy link

Choose a reason for hiding this comment

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

It doesn't work if we try to desugar several declarations this way:

export a = 2 -- 1
a = 3        -- 2
export b = 4 -- 3

If table.freeze happens before 2, then 3 should rise an error.
If table.freeze happens at the end 2 shouldn't be an error.
It is easer to use const semantic here

Copy link
Contributor

@alexmccord alexmccord Jan 29, 2026

Choose a reason for hiding this comment

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

Elaboration is not desugaring and is allowed to fail. Consider:

export a = 1
print(a)
export a = 2
print(a)

If we desugar to its const form, the above snippet is valid because const is allowed to shadow. This is effectively the same as your example. You'd need a rule in this case that rejects any export whose name have already been exported. This makes exports inexpressible in terms of const bindings.

Copy link

Choose a reason for hiding this comment

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

I would consider that code snipped valid. It outputs 1 and 2 and exports a as 2.
I understand it can be confusing for corner cases, but I think this approach makes the language more consistent in design choices.
We could prevent this kind of behavior to disallow constant shadowing on the same level(as JS does), allowing it only in nested scopes. I think it is a good idea in general, but it contradicts Lua(u)'s behavior of shadowing rules for locals. So I would like not to do it to stay consistent.
Also, I consider idiomatic to use "local" intuition for explaining Lua(u)'s syntactic sugar.
For example, local function f is traditionally explained as local f + f = function() formula. Desugaring export with const we keep it 'local' in reasoning without bringing global file-level semantic.


Exported values and functions are implicitly [`const`](https://github.com/luau-lang/rfcs/pull/166). This means that exports must be initialized at the point of declaration and cannot be reassigned after initialization.

```luau
export foo = 5
foo = 6 -- error: exported bindings are const
```

Without this, it would be possible to mutate the variable without changing to the exported table, leading to the following foot-gun:

```luau
export foo = 5

export function increment()
foo += 1 -- 'foo' won't change in the exported table
end
```
Comment on lines +48 to +54
Copy link
Contributor

@alexmccord alexmccord Jan 29, 2026

Choose a reason for hiding this comment

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

This is quite awkward. What is the value of mod.foo after you call mod.increment()?

local foo = 5

export function increment()
	foo += 1
end

export foo -- ???

Currently, mod.foo after calling mod.increment() would always be 5 because it copies the value of foo into the field foo of the exported table. Instinctively, I want it to be 6 because, to me, export foo looks like it exports an alias to the inner foo which is distinct from export foo = foo which copies the value of foo into the read-only symbol foo.

I know that this RFC says export foo is sugar for export foo = foo, but I'm not so sure about that now, it enables a footgun:

local foo = 5

export function increment()
  foo += 1
end

export foo

export function get()
  return foo -- this will always be 5
end

And we can't even have a linter fire a warning on this (doesn't make sense).

A fix is to be isomorphic to something akin to this, using __index to provide aliasing.

local foo = 5

local function increment()
  foo += 1
end

-- this is an empty table because of `for k, v in mod do` which conflicts with aliasing
return table.freeze(setmetatable({}, {
  __index = function(_, k)
    return if k == "foo" then foo
      else if k == "increment" then increment
      else nil
  end,
}))

Copy link

Choose a reason for hiding this comment

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

I agree this approach would be more flexible and beneficial for some code patterns, but in this case we will loose ability to statically reason about exported values during loading. It will impede some potential optimizations for cross module inlining and constant folding. As soon as Luau doesn't allow to control readonlyness with property level granularity we will need to make some compromises here.
User intention can be expressed using getter function or intermediate object.

Copy link
Contributor

@alexmccord alexmccord Jan 30, 2026

Choose a reason for hiding this comment

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

I mean, an optimization you can do is literally just move some of the exported symbols into the table part for all exported bindings that have not been mutated (or rather, not mutated through functions). From that you have a guarantee that increment always points to the same function object, whereas foo still goes through __index.

The reason why I didn't do that was because of for k, v in mod do would not iterate over foo. But here, let's just do that. The downside is that now pairs(mod)/rawget(mod, "increment") is weird.

local order = {"foo", "increment"} -- sorted by hash, just like hashmaps
return table.freeze(setmetatable({ increment = increment }, {
  __index = function(_, k)
    return if k == "foo" then foo
      else nil
  end,
  __iter = function(self)
    local i = 0
    return function()
      i += 1
      local k = order[i]
      return k, self[k]
    end
  end,
}))

Problem solved, the compiler or VM can still see that increment field exists in the table and did not require __index.

Module systems are complicated for a reason. You can't hand-wave all the issues away in an RFC with 165 lines that barely even talks about edge cases.

Copy link
Contributor

Choose a reason for hiding this comment

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

I say if you plan on reintroducing assignability through shorthand exports, don't even bother making exported values unassignable in the first place. I'm actually leaning more in the camp of not even supporting shorthand exports because of this issue.

In my personal opinion, exporting should just be pure syntax sugar that maps exactly to returning a plain frozen table. There should be no special metatable mechanics like the proposed aliasing rules in the desugared form.

Copy link
Contributor

@alexmccord alexmccord Feb 8, 2026

Choose a reason for hiding this comment

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

Yeah, I think just remove the shorthand export. This RFC is proposing CommonJS-esque modules, not anything fancy like ES modules which has live bindings.


#### Export Table Construction

Rather than populating an export table incrementally, the set of exported bindings is collected during parsing. When module execution completes, the module implicitly returns a frozen table containing the exported bindings.

For example, the following module:

```luau
export type Point = { x: number, y: number }

export function distance(a: Point, b: Point)
local x, y = a.X - b.X, a.Y - b.Y
return math.sqrt(x * x + y * y)
end
```

Can be considered sugar for:
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure the RFC should show the desugared representation with any constructs that doesn't exist.

Copy link

Choose a reason for hiding this comment

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

I think it is acceptable as soon as dependency on other RFC explicitly stated and const RFC is not rejected yet.
Of course export proposal cannot be land in this form until const proposal is.


```luau
export type Point = { x: number, y: number }

const function distance(a: Point, b: Point)
local x, y = a.X - b.X, a.Y - b.Y
return math.sqrt(x * x + y * y)
end

return table.freeze({
distance = distance,
})
```

#### Export Shorthand

For convenience, we provide a shorthand form of `export` which will export an existing binding with the same name:

```luau
const foo = computeFoo()
export foo
```

If `foo` was declared as `local` instead of `const` then subsequent uses of it here will be treated as if it were declared as `const`.

#### Order of Declarations and Mutual Dependencies

As with `local` and `const` exports are evaluated in source order. Using the shorthand from above, mutually recursive or dependent functions can be declared before they are exported.

```luau
function a()
return b()
end

function b()
return 42
end

export a
export b
```

Exporting before a binding is initialized is an error. Supporting hoisted exports or forward declarations is out of scope for this proposal and may be explored in a future RFC.
Copy link
Contributor

@alexmccord alexmccord Jan 13, 2026

Choose a reason for hiding this comment

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

Exporting before a binding is initialized is an error.

Agreed.

Supporting hoisted exports or forward declarations is out of scope for this proposal and may be explored in a future RFC.

Disagree. This is not backward compatible to retrofit and as such it is not a decision we can defer until later, and mutually dependent functions are fundamental in nontrivial programs. Pre-existing idiom allows mutual dependencies already, so this RFC is more or less a downgrade in its current form. In short, this is a design choice we have to make now or never, and deferring a backward-incompatible design choice is the same as choosing to never do it.

If we could resolve names in function bodies after parsing the body itself rather than immediately, then we already support mutual dependencies. This is sort of easy from a conceptual point of view: after parsing the module, all occurrences of AstExprGlobal implies a free variable that are yet to be bound. Walk each occurrence and check if an export binder name exists and then rebind that occurrence to the export binder.

export type t<a> =
  | { type: "leaf", value: a }
  | { type: "node", children: {t<a>} }

export function walk_node<a, b>(
  node: t<a>,
  leaf_fn: (a) -> b,
  node_fn: ({b}) -> b
): b
  return if node.type == "leaf"
    then leaf_fn(node.value)
    else node_fn(walk_children(node.children, leaf_fn, node_fn))
end

export function walk_children<a, b>(
  children: {t<a>},
  leaf_fn: (a) -> b,
  node_fn: ({b}) -> b
): {b}
  local result = table.create(#children)
  for _, node in children do
    table.insert(result, walk_node(node, leaf_fn, node_fn))
  end
  return result
end

As you can see, it is not possible to reorder either walk_node or walk_children. Initially, when the AST is produced, the walk_children at line 12 is an AstExprGlobal which is analogous to a free variable in lambda calculus, and during name resolution we find out that we could bind that walk_children to the binder export function walk_children turning it into a bound variable. Voila, you have mutual dependencies.

This approach will require changes in everything else though, since if export function walk_children defines a local named walk_children, then the reference at line 12 must be an AstExprLocal, which lexically occurs before its declaration! There's a bunch of ICEs that will happen and needs to be taken care of. Presumably the bytecode compiler requires more fixes as well if this was added (@vegorov-rbx?).

With that said, I will be contrarian for a minute because there are problems on the other side of this beyond the existing implementation assumptions that needs to be solved if we decided to support mutual dependencies out of the box, because there's a critical caveat to note: we don't want to rebind any AstExprGlobal to any exports before it was declared except those inside of a function body, and the above approach must continue to maintain this invariant, otherwise you'll end up with nasty bugs in the program like this:

print(nasty()) -- nil

local x = 5

export function nasty()
  return x
end

Which is precisely equivalent to JavaScript's hoisting bug:

console.log(nasty()); // undefined

var x = 5; // change to `let` or `const` for TDZ semantics

function nasty() {
  return x;
}

ES6 added let/const as well as TDZ (short for "temporal dead zone") semantics. Note that we can't make this a compile error because of the below snippet which is "technically well-defined" as per the rule I gave earlier, but is still ill-formed because local x = 5 has yet to be executed:

export function hella_nasty()
  return nasty()
end

print(hella_nasty())

local x = 5

export function nasty()
  return x
end

Unfortunately it is not possible to turn this class of bugs into a compile error because the analysis is nontrivial, and, per Rice's theorem, is undecidable in the general case (hence why TDZ was punted onto the runtime), so if we did support mutual dependencies, then we have these choices:

  1. TDZ semantics (please, for the love of god, no)
  2. Don't allow module-level code to call exported functions
  3. OCaml-style and for explicit grouping of mutual dependencies.
    export function f()
      ... -- g is available here
    end
    and g()
      ... -- f is available here
    end

Copy link

@SPY SPY Jan 27, 2026

Choose a reason for hiding this comment

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

I don't think we need to introduce hoisting for this purpose.
It brings more harm than convenience.
I would prefer the code snippet above can be expressed with usage of temporary locals.

export type t<a> =
  | { type: "leaf", value: a }
  | { type: "node", children: {t<a>} }

local walk_children: <a, b> (
  children: {t<a>},
  leaf_fn: (a) -> b,
  node_fn: ({b}) -> b
) -> {b}

export function walk_node<a, b>(
  node: t<a>,
  leaf_fn: (a) -> b,
  node_fn: ({b}) -> b
): b
  return if node.type == "leaf"
    then leaf_fn(node.value)
    else node_fn(walk_children(node.children, leaf_fn, node_fn))
end

walk_children = function<a, b>(
  children: {t<a>},
  leaf_fn: (a) -> b,
  node_fn: ({b}) -> b
): {b}
  local result = table.create(#children)
  for _, node in children do
    table.insert(result, walk_node(node, leaf_fn, node_fn))
  end
  return result
end

export walk_children

I also like OCaml style approach. In this case my snippet will be the desugaring of it.


#### Calling Exported Functions Internally

Exported functions are still available within the module and can be called normally:

```luau
export function distance(a: Point, b: Point)
return math.sqrt((a.X - b.X)^2 + (a.Y - b.Y)^2)
end

distance({0, 0}, {1, 1})
```

#### Nested Tables

Exporting a table exports the binding, not the contents of the table:

```luau
export triangle = {}

function triangle.draw()
-- ...
end
```

The `triangle` binding is immutable, but the table itself is mutable unless explicitly frozen.

#### Returns

A module that uses `export` for values or functions may not also return a custom value. Attempting to do so is an error:

```luau
export function distance(a: Point, b: Point)
return math.sqrt((a.X - b.X)^2)
end

return { distance = distance } -- error
```

Type-only exports may continue to coexist with an explicit return value, as they do today.

## Drawbacks

* Introduces another way to define module exports alongside explicit return tables.
* Requires exported bindings to be immutable, which may require restructuring some existing patterns.
* Extends the surface area of the `export` keyword beyond types.

## Alternatives

- Modules could implicitly export all top-level bindings. This is not backwards compatible, reduces explicitness, and complicates reasoning about module APIs.
- Modules can already export values by returning tables explicitly. This proposal is a quality-of-life and consistency improvement but is not strictly necessary.