Date: 2026-05-19 Status: Accepted
Decision Makers: Maintainer Tags: luau, types, config, distribution, testing, lute, public-api
Bedrock supports five config formats via c12: TypeScript, JavaScript, YAML,
JSON, and Luau. ADR-020 defines the shared Config shape and the defineConfig
helper that the TypeScript surface exposes through @bedrock-rbx/core/config. A
TypeScript author writes
import { defineConfig, type Config } from "@bedrock-rbx/core/config" and gets
IDE autocomplete, type errors on misspelled fields, structural validation at
edit time, and the strict universeId XOR enforced as a discriminated union with
branded-error messages that point at the offending field. A Luau author writes a
bare table literal and gets none of that. Arktype validates either author's
config at load time so deploy-time errors are structured, but the gap between
edit-time experience and deploy-time experience is meaningful, and doubly so for
agents authoring configs on behalf of users.
ADR-017 names Luau-population developers as a peer audience to TypeScript
developers, not a fallback. The expected Luau author runs the bundled bedrock
CLI standalone (via mise or rokit) rather than installing the npm package;
their editor reaches type definitions through .luaurc aliases, not
node_modules. So shipping Luau types is not a matter of adding a .d.luau to
the npm package and trusting resolution to handle the rest. Distribution is a
first-class concern.
Three constraints shape the design space:
-
Luau new-solver limitations. Luau's new type-solver does not narrow object unions from literals (luau-lang/luau#2205). Annotating a literal against
ConfigRootUniverseId | ConfigEnvironmentUniverseIdproduces a multi-bullet fan-out enumerating per-component mismatches across both variants, including against valid configs. Wrapper functions over the literal hit related problems: generic identity wrappers force exact-match comparisons that flag valid configs as invalid, overload-intersection signatures collapse to "no overloads compatible" without surfacing variant-specific directives, and unchecked::casts silently accept XOR violations. These behaviors were verified across a dozen spike fixtures before this decision. -
API symmetry across the two languages. The TypeScript public API is
defineConfig({...})over a discriminatedConfigunion; users do not annotate manually because TypeScript narrows on the literal. Diverging the Luau API (introducing per-variant factory functions, requiring a variant annotation on the receiving variable, or adding a discriminator field to the schema) breaks the symmetry that motivates havingdefineConfigat all. -
Hand-author duality already in the codebase. ADR-020 paired the TypeScript
Configinterface with a hand-written arktype validator; maintainers update both surfaces per schema change today. Adding a Luau surface as a third hand-author is an increment, not a redesign.
The decision space narrowed to: encode the full TypeScript shape including the XOR variants and accept a degraded Luau diagnostic experience; add a discriminator field to the schema as a workaround for the Luau limitation; or ship a permissive Luau shape that defers XOR enforcement to the existing arktype runtime narrow. This ADR records the third path.
The Luau Config type mirrors the TypeScript Config structurally with one
departure: it is a single permissive shape rather than a discriminated XOR
union. Every field optional in TypeScript is also optional in Luau. The root
universe.universeId and every per-environment
environments[*].universe.universeId are typed string?. No singleton-string
directive types. No brand-intersection encoding.
Arktype's existing runtime narrow at loadConfig time enforces the XOR rule and
produces directive messages identical to the TypeScript branded-error
placeholders. A Luau author who declares universeId at both the root and a
per-environment overlay sees the directive at deploy time rather than edit time.
The trade-off is bounded: arktype already attributes the failure to the
offending field path; only the surfacing moment moves from editor to CLI
invocation.
The permissive type is a structural supertype of the discriminated TypeScript variants. When luau-lang/luau#2205 closes (or another narrowing mechanism lands), the Luau type can be refined into the discriminated union without breaking existing user configs.
The Luau module exports defineConfig as an identity-typed function:
local function defineConfig(c: Config): Config
return c
endThe parameter is a concrete Config (the permissive shape from above), not a
union or a generic. Runtime is identity. The signature mirrors the TypeScript
shape defineConfig<T extends Config>(c: T): T modulo the Luau type-system's
inability to express the generic-identity bound without forcing exact-match
comparisons on the literal.
Function name and call shape are identical across languages. The TypeScript user
writes defineConfig({...}); the Luau user writes defineConfig({...}). No API
divergence.
A new CLI command, bedrock setup, materializes the Luau source bundled inside
the bedrock binary into .bedrock/config.luau in the user's repository and
merges an @bedrock alias into the user's .luaurc. The alias points at the
.bedrock/ directory; users require("@bedrock/config") to import the module.
The alias subpath structure (@bedrock/config) mirrors the TypeScript export
path (@bedrock-rbx/core/config).
The same setup flow materializes additional .bedrock/ files in future slices
(e.g. @bedrock/testing for the type-spec primitives). The @bedrock alias is
intentionally a directory alias, not a file alias, so future module additions
slot in without another .luaurc change or a re-run requirement on the user.
bedrock setup is the single mutating command for these files; every other
bedrock subcommand is read-only with respect to .bedrock/ and .luaurc.
Three flags control its behavior:
--alias <name>overrides the default alias name when a user already has an@bedrockalias pointing elsewhere.--types-path <path>overrides the default.bedrock/config.luaulocation.--checkruns the staleness check (described below) explicitly without performing any writes.
The chosen alias is persisted in setup state so subsequent runs without
--alias honor it.
Distribution works the same way for users on the npm install path and users on
the bun-compiled standalone binary. The Luau source files live at
packages/bedrock/src/luau/ in the source tree, ship to dist/luau/ for npm
consumers, and are embedded into the binary via Bun's asset embedding mechanism
for standalone consumers. bedrock setup reads from the appropriate location at
runtime.
Every bedrock subcommand other than setup calls a content-hash comparison
against .bedrock/config.luau before its main logic. On mismatch (the on-disk
file does not match the version embedded in the running binary), the command
prints a stderr warning naming the file and instructing bedrock setup as the
fix. Execution continues regardless. Missing on-disk content produces no
warning, since a user who has not run setup is not in a staleness condition.
The check never blocks execution and never mutates files. Auto-fix was rejected:
.bedrock/config.luau lives in the user's working tree and is likely checked
into version control, where surprising mutations to a file the user may be
inspecting in their editor are hostile.
Two opt-outs:
BEDROCK_SKIP_TYPES_CHECK=1environment variable suppresses the check entirely for a single process invocation. Intended for CI environments that prefer to suppress noise.bedrock setup --checkruns the same check explicitly; exit code 0 if fresh, non-zero if stale. Suitable for pre-commit hooks or CI gates that want to fail loudly rather than warn quietly.
Type-spec tests live in packages/bedrock/tests/luau-types/. Each fixture is a
.spec.luau file using a describe/it block structure that mirrors
.spec-d.ts files on the TypeScript side. The fixture uses three primitives:
| Primitive | Role | TypeScript analog |
|---|---|---|
describe(...) / it(...) runtime stubs |
Make test boundaries discoverable to the AST walker | Same |
-- @expect-error directive |
Assert that the following line produces a lute check diagnostic |
// @ts-expect-error |
Expect<A, B> type-level alias |
Assert that two types are structurally equal | expectTypeOf<A>().toEqualTypeOf<B>() |
describe and it are exported as runtime no-op functions from a new
@bedrock/testing Luau module that bedrock setup materializes alongside
@bedrock/config. Their sole purpose is to be findable by the AST walker.
Expect<A, B> is a hand-rolled type function in the same module that errors
when the two types are not structurally equal.
The Vitest harness orchestrates the pipeline: glob fixtures, run
lute transform against a parser script to extract describe/it line ranges, run
lute check and parse diagnostics, cross-reference diagnostics with
@expect-error directives and test ranges, produce per-fixture pass/fail. A
directive without a following diagnostic is an unused-directive failure; a
diagnostic on a line without a preceding directive is an unexpected-error
failure. The harness asserts diagnostic presence; message text is intentionally
not asserted because wording over-binds to specific Luau toolchain versions.
The harness module structure (parse-tests.luau, parse-diagnostics.ts,
parse-directives.ts, runner.ts) mirrors jest-roblox-cli/src/typecheck/ so
the prototype lifts to that project as a Luau-spec subcommand when ready.
A vitest-style fluent expectTypeOf(value).toEqualTypeOf(other) API is not part
of the day-1 surface. The same Luau new-solver limitation that drives the
schema-scope decision suppresses type-function errors inside generic function
bodies, so the fluent shape silently passes every test regardless of type
mismatch. The type-level Expect<A, B> is the day-1 form; a fluent value-level
API can layer on top when the underlying limitation is resolved.
Drift between the TypeScript and Luau schemas is enforced by code review, not by
an automated structural diff. The project /review skill is extended to flag
any change to packages/bedrock/src/core/schema.ts that lacks a parallel change
to packages/bedrock/src/luau/config.luau, and vice versa. The check excludes
the deferred-XOR delta from "drift" (the TypeScript schema has discriminated
variants with branded-error properties; the Luau schema does not, intentionally,
per this ADR).
The type-spec fixture suite is the safety net for the most common drift classes: a field added to a TypeScript entry type without the matching Luau addition surfaces as a missing-field diagnostic when a fixture exercises the new field.
- Luau authors and agent-authored configs get editor-time type safety on misspelled fields, wrong-typed values, and unknown resource kinds, closing the parity gap with TypeScript authors.
defineConfighas the same name and call shape across both languages, preserving the symmetric API surface that ADR-017's peer-audience framing demands.bedrock setupis the only mutating command for the Luau type files; combined with the warn-only auto-check, users keep full control of their working tree.- The type-spec testing pattern (
describe/it+@expect-error+Expect<A, B>) enables TDD on Luau type changes. The harness module structure is lift-ready into jest-roblox-cli for future reuse. - The permissive Luau
Configis a structural supertype of the discriminated TypeScript variants, so refining the Luau type later when luau-lang/luau#2205 closes does not break user configs written against the permissive shape. - The
@bedrockalias is a directory alias, leaving room for future Luau-side modules (@bedrock/runtime, generated constants, etc.) without another.luaurcchange.
- The XOR rule between root and per-environment
universeIdis not enforced at edit time on the Luau side. Luau authors see the failure at deploy via arktype rather than in the editor. - Hand-author duality grows from two surfaces (TypeScript interface + arktype
validator) to three (add Luau type), increasing maintenance per schema change.
The
/reviewskill becomes load-bearing for parity enforcement. - Luau users must run
bedrock setuponce before their editor picks up the types, adding a step to onboarding. - Lute is a hard dependency for both the type-spec test harness and the binary
embedding mechanism. Toolchain pinning via
mise.tomlis now consequential. - The type-spec test harness is bedrock-internal infrastructure until
jest-roblox-cli adopts it. The
-- @expect-errordirective convention is locally invented and not portable to other Luau testing frameworks. - The deferred-XOR creates an intentional asymmetry between the TypeScript discriminated variants and the Luau permissive shape. Future maintainers must respect that the asymmetry is intentional rather than treat it as drift.
Rejected because: Luau's new type-solver does not narrow object unions from
literals. Annotating a literal against
ConfigRootUniverseId | ConfigEnvironmentUniverseId produces a multi-bullet
fan-out enumerating per-component mismatches across both variants. Verified
during spike work, where both valid root-only and valid env-only configs
produced 9-12 bullet diagnostics. "Verbose-but-shipped" is not a viable middle
ground because the verbosity fires on correct configs, not just XOR violations.
Rejected because: this works at the type-checker level, with a non-generic
defineConfig(c: Config): Config producing clean variant-pinned diagnostics in
both directions. But the discriminator is a schema field added purely to work
around a transient Luau type-solver limitation. Adding it locks every user
config into carrying a redundant field that arktype would derive on its own from
universeId placement. Removing it later once Luau ships union narrowing is a
wire-contract change.
Rejected because: both shapes produce clean variant-pinned diagnostics. Two
factories (defineConfigWithRootUniverse /
defineConfigWithEnvironmentUniverses) work because each parameter is a single
non-union variant; dropping defineConfig and requiring
local config: ConfigRootUniverseId = {...} works because the annotation pins
the variant. Both diverge from the TypeScript API in user-visible ways: the
first adds two named functions where TypeScript has one, the second removes the
function entirely. ADR-017 names API symmetry across the two audiences as a
property worth preserving, not a coincidence to be sacrificed.
Wrapper-function encodings of defineConfig (identity generic, overload intersection, unchecked cast)
Rejected because: every wrapper that influences types degrades the
diagnostic. A generic identity wrapper (defineConfig<T>(c: T): T) infers T
from the literal and forces exact-match comparisons that flag valid configs as
invalid. An overload-intersection signature collapses to "None of the overloads
for function that accept 1 arguments are compatible" without surfacing the
variant-specific directive. An unchecked :: cast silently accepts XOR
violations entirely. The only wrapper-free path that preserves the clean
diagnostic is direct variant annotation, which the previous alternative
addresses.
Rejected because: the TypeScript schema is already hand-paired with an
arktype runtime validator per ADR-020; maintainers update both surfaces per
schema change. Adding a Luau surface as a third hand-author is incremental. A
generator would need per-construct rules for TypeScript features Luau cannot
express (branded errors, key-pattern indexers, the XOR discriminated union
itself), which is substantial machinery for the same outcome a /review skill
update achieves.
Rejected because: the same Luau new-solver limitation that drives the
schema-scope decision suppresses type-function errors inside generic function
bodies. A wrapper like
expectTypeOf<T>(_: T): { toEqualTypeOf: <U>(_: U) -> () } looks correct
syntactically but silently passes every test regardless of type mismatch. The
type-level Expect<A, B> form works correctly; a fluent value-level API can
layer on top when the underlying limitation is resolved.
Rejected because: the deferred-XOR delta is an intentional asymmetry between
the two schemas. A structural diff would need a hand-maintained allow-list of
intentional differences that grows every time the asymmetry expands. The
/review skill is a higher-leverage place to capture the parity expectation
because the skill already encodes domain context that an automated diff cannot.
Rejected because: .bedrock/config.luau lives in the user's working tree
and is likely checked into version control. A bedrock deploy invocation that
silently rewrites a file the user may be inspecting in their editor, or that
turns up as an unexpected diff at commit time, is hostile. bedrock setup being
the single mutating command is the predictable mental model; the auto-check
warns the user so they choose when to run setup.
- The Luau source files live at
packages/bedrock/src/luau/config.luauandpackages/bedrock/src/luau/testing.luau. Per-resource entry types compose into the rootConfigtype in the same file. - The
bedrock setupcommand is implemented aspackages/bedrock/src/cli/commands/setup.tswrapping a shell function. The.luaurcparse/merge logic is a pure function insrc/core/luaurc.ts; I/O orchestration lives in the shell layer. - The staleness check uses a content hash of the bundled and on-disk Luau sources. Any byte difference counts as stale.
- Bun's asset embedding mechanism carries the Luau source files into the
compiled binary. For npm consumers, the same files ship under
dist/luau/viavp pack. - The type-spec harness lives at
packages/bedrock/tests/luau-types/. Module structure mirrorsjest-roblox-cli/src/typecheck/so the prototype lifts to that project as a Luau-spec subcommand. - Lute is the analyzer:
lute checkproduces structured diagnostics;lute transformwalks the AST. Pinned viamise.toml. - Implementation is tracked via PRD #441, broken into six AFK-ready slices.
- ADR-003 — Testing Strategy: TDD and 100% coverage apply to the new modules; the type-spec harness extends the testing surface to Luau.
- ADR-009 — Result Types Over Exceptions:
setupLuauTypesandcheckLuauTypesStaleshell functions returnResult. - ADR-017 — Product Framing: peer-audience principle that motivates same-named APIs across TypeScript and Luau.
- ADR-018 — FCIS Ports: pure
.luaurclogic lives in core; setup orchestration lives in shell; the CLI command is a primary adapter. - ADR-020 — Project Config Definition: the TypeScript-side
ConfiganddefineConfigthat this ADR mirrors for Luau.
- Lute — Luau runtime used as the
type-checker (
lute check) and AST walker (lute transform). - luau-lang/luau#2205 — upstream Luau issue tracking object-union narrowing from literals; defines the forward-compatibility path.
- typeforge — reference
implementation of Luau type functions; the
Expect<A, B>pattern is hand-rolled in the same style. - jest-roblox-cli — lift
target for the type-spec harness; module structure here intentionally mirrors
src/typecheck/. - PRD #441 — implementation specification.
- Issue #315 — predecessor issue raising the question.
- docs/spikes/luau-types/ — feasibility evidence with 11 numbered sections covering every encoding pattern explored.