lloyAlloy on GitHub

← Every RFC

The std by import

ConfigAcceptedThe file on GitHub →

Summary

A file imports the std names it uses: import { HashMap, Set } from "@alloy/std/collections". The std is laid out in modules by subject, and @alloy/std re-exports all of them. The names the language owns stay ambient: Future, Result, Ok, Err, Array, and the operator traits. A project that wants more ambient names says so: [std] globals = "all" makes every std name ambient, and a list, globals = ["Signal", "Iter"], makes those names ambient. Serialize and Deserialize move to @alloy/std/serde, and @derive takes them as imported names. The emit does not change in any case.

Motivation

Every std name was ambient. HashMap, Queue, Iter, Result, Ok, Err, Signal and about fifteen more were in scope in every file, with no line saying so:

local m = new HashMap()
local r: Result<number, string> = Ok(1)

That is short, and it costs three things.

A reader cannot tell where a name comes from. HashMap and a name the project declares in another file look the same at the use site, and only Result has a chapter a reader would think to open.

A name the author wants is taken. A game with its own Signal or its own Set either renames it or shadows the std one, and the shadow is silent.

The ambient list grows. Every std type added takes a name from every project that exists, and the project finds out when a build breaks.

The runtime already does not need the ambient names. The emit qualifies every one of them:

local m = new HashMap()
local __alloy = require("@alloy") local m = __alloy.HashMap.new()

So the ambient name is a source convenience, not a runtime fact, and turning it off costs nothing at run time.

Design

The option

alloy.tomlalloy.toml
[std]
# "none" (the default), "all", or a list of names to keep ambient.
globals = "none"

Three values:

The list is the value to teach for a project that wants a few names everywhere. It names what the project treats as its own vocabulary, a Signal in a game built on events, an Iter in one built on pipelines. It never has to name the language's own names, which the next sections keep ambient.

The option sits in its own [std] table rather than in [emit], because it changes name resolution and not the emit. alloy init writes globals = "none", so a new project shows the key.

A file outside any project reads the default, "none".

The modules

The std has nine modules. One module holding every name is a list to scroll, not a library to learn, so the names sit by subject:

moduleholds
@alloy/std/collectionsHashMap, Set, Queue, Heap, Array, Symbol, BitSet
@alloy/std/iterIter
@alloy/std/resultResult, Ok, Err
@alloy/std/asyncFuture, Scope
@alloy/std/signalSignal, SignalConnection, Signalish
@alloy/std/traitsDisplay, Debug, Clone, Default, Eq, PartialEq, Ord, Add, Sub, Mul, Div
@alloy/std/serdeSerialize, Deserialize, and the attributes rename, rename_all, skip, deny_unknown_fields
@alloy/std/typesPartial, Readonly, Sink
@alloy/std/robloxR15Character, R6Character, Attributes

A name sits in one module. Sink is a mapped type, the write half of Readonly, so it sits with the type utilities and not with the signals. The table lists the names a source can write; a type the runtime uses on its own, such as Awaitable, has no entry.

@alloy/std re-exports every module, so a file may name the group or name the library:

import { HashMap, Set } from "@alloy/std/collections"
import { Signal } from "@alloy/std/signal"
import type { Partial } from "@alloy/std/types"

-- The same names, through the facade.
import { HashMap, Signal } from "@alloy/std"

The grouped form is the one to teach, because it says what a name is for. The facade exists so a file with four names from four modules is one line.

The forms an import takes

Every import form a module takes works on the std:

A default import, import std from "@alloy/std", is an error: the std has no default export, and the report names the star form.

A std import is allowed under every value of the option. Under "all" it adds nothing, and a file that wants to be explicit may write it. An unused std import draws unused_import, as any import does.

The names the language owns

A keyword or an operator writes std names the author never types, and a failure idiom reads them back. Asking a file to import a name for a keyword it wrote would be absurd, so these names are the language's, and they stay ambient under every value of the option:

written bythe names
async, awaitFuture
try, and a Result returnResult, Ok, Err
[ ] literals and T[] typesArray
==, <, +, -, *, /, tostring, clonethe traits of @alloy/std/traits

The traits are in the table because they are the hooks the operators call. @derive(Eq, Debug, Clone, Default) and a bound <T: Ord> need no import for the same reason. A name only the author writes is importable: the collections, the signals, the iterators, the type utilities, and serde.

The owned names are still importable from their module, for a file that prefers to be explicit.

Serialize and Deserialize

@derive(Serialize) wrote both directions: to_table, serialize, and from_table. The two halves now split, the way Rust's serde splits them:

A struct that round-trips derives both. A field whose type is a struct of the file that derives the same half goes through that struct's own function, so the table nests on the way out and the metatable comes back on the way in. @rename and @skip apply to both halves.

Both names live in @alloy/std/serde, and a derive argument is a name like any other. A file imports it the way it imports a type:

import { Serialize, Deserialize } from "@alloy/std/serde"

@derive(Serialize, Deserialize, Eq)
struct Save
    coins: number
end

local function store<T: Serialize>(value: T) return value:serialize() end

So the answer to "how does a derive argument get imported" is the answer for every other name: an import binds it, and a user trait that the derive can write is imported from its own module the same way.

The options the serde derives read are std names of the same module: @rename, @rename_all, @skip, and @deny_unknown_fields. Each one needs its import, as Serialize does, and alloy flux --fix writes it. A star import reaches the options and the derives through the module:

import * as serde from "@alloy/std/serde"

@derive(serde.Serialize, serde.Deserialize)
@serde.deny_unknown_fields
struct Save
    @serde.rename("hp")
    health: number
end

@alias stays built in. It gives a field a second key that reads and writes the same slot, which a struct does with or without serde.

What the modules cost

Nothing at run time. A std import writes no require: the name renders as __alloy.Name wherever it stands, the way an ambient name does.

import { HashMap } from "@alloy/std/collections"

local m = new HashMap()
local __alloy = require("@alloy") local m = __alloy.HashMap.new()

So the layout is a naming device. The compiler holds one table from name to module, the modules resolve to no path on disk, and two files that import one name through different specs produce identical output. A star import writes one require of the runtime, the module the file already requires.

The prefix is @alloy/std rather than @alloy so the toolchain keeps room for a surface that is not the library, @alloy/testing for the assert macros, for example.

The report

A std name that the file does not reach reports once per name, at its first use, with the line to write:

error(3.2): ImportError: `HashMap` is in the std; write `import { HashMap } from "@alloy/std/collections"`

The message names the import rather than saying the name is unknown, because the name is not a typo and the reader needs the line, not the diagnosis. The one table from name to module means the report can name the right module, and it means a name imported from the wrong one is its own report:

error(3.2): ImportError: "@alloy/std/collections" has no `Signal`; it is in "@alloy/std/signal"

A module the std does not have lists the ones it has. A name that is ambient under the project's option draws no report.

Migration

alloy flux --fix and alloy lint --fix write every missing import. A name joins an import { } from list the file already has for the facade or for its module, and the rest go on new lines below the last import, one line per module. A project that updates the compiler runs the command once. A project that wants the old behavior sets globals = "all".

A struct that derived Serialize and read its value back with from_table adds Deserialize. The type checker reports the missing from_table, so the gap is visible.

The option is per project. A library and the game that uses it may disagree, because the setting decides how a file is written, not what the build produces.

The language server

Drawbacks

Alternatives

Prior Art