Skip to main content

.d.ts generation

In directory and Rojo-project modes, luau2ts emits a TypeScript declaration file (.d.ts) alongside each compiled .ts, capturing the inferred shape of the module's exports.

When it fires

.d.ts emission runs automatically when:

  • The output target is a directory (luau2ts src/ -o out/) or a Rojo project (luau2ts -p default.project.json -o out/).
  • The mode is rbxts. Native mode skips emission for now (the generated types reference @rbxts/types globals like defined and RBXScriptSignal).
  • The script is a ModuleScript (plain .luau, not .server.luau or .client.luau). Scripts and LocalScripts don't have a return value to type, so no .d.ts is written.

Single-file mode (luau2ts foo.luau -o foo.ts) does not emit a .d.ts because the cross-script index isn't built for a single file.

Output location

.d.ts files land in a sidecar .types/ directory under the output root, mirroring the source layout:

out/
├── ReplicatedStorage/
│ └── Modules/
│ └── Foo.ts # compiled
└── .types/
└── ReplicatedStorage/
└── Modules/
└── Foo.d.ts # declaration

The .types/ placement keeps the compiled .ts tree clean for roblox-ts consumption while preserving the declarations as a separate artifact.

File shape

Each .d.ts declares a single default export matching what the Luau module returns:

// Generated by luau2ts v0.3.0 (do not edit).
declare const _default: {
/* inferred exported shape */
};
export default _default;

A hand-written TypeScript consumer can import Foo from "./Foo" and the inferred shape comes along.

Examples

Signals: ToiletsAPI

Luau source:

local OnEquippedChanged = Instance.new("BindableEvent")

local API = {}
API.OnEquippedChanged = OnEquippedChanged.Event

function API.GetMultiplier(player) ... end
function API.Sync(player) ... end
function API.Roll(player, crateId) ... end
function API.Equip(player, guid) ... end
function API.Unequip(player, guid) ... end
function API.OnPlayerJoined(player) ... end

return API

Generated .d.ts:

declare const _default: {
OnEquippedChanged: RBXScriptSignal;
GetMultiplier(...args: unknown[]): defined;
Sync(...args: unknown[]): defined;
Roll(...args: unknown[]): defined;
Equip(...args: unknown[]): defined;
Unequip(...args: unknown[]): defined;
OnPlayerJoined(...args: unknown[]): defined;
};
export default _default;

The Instance.new("BindableEvent") pattern is detected, so OnEquippedChanged exports as RBXScriptSignal (callable via .Connect, .Wait, .Once) rather than collapsing to defined. Methods declared via function M.foo(...) come through as method signatures; (...args: unknown[]) keeps the call variadic-tolerant where the body doesn't pin down a primitive param type.

Nested dict-of-records: ToiletDefinitions

Luau source:

local Defs = {
rarities = {
Common = {color = Color3.fromRGB(180, 180, 180)},
Rare = {color = Color3.fromRGB( 70, 150, 220)},
Epic = {color = Color3.fromRGB(180, 110, 220)},
Legendary = {color = Color3.fromRGB(255, 180, 60)},
},
toilets = {
plain = {displayName = "Plain Toilet", rarity = "Common", multiplier = 1.05},
squeaky = {displayName = "Squeaky Toilet", rarity = "Common", multiplier = 1.10},
gold = {displayName = "Gold Toilet", rarity = "Rare", multiplier = 1.30},
},
crates = {
basic = {
displayName = "Basic Crate",
price = {cash = 1500},
weights = {plain = 50, squeaky = 30, gold = 15},
},
},
}

return Defs

Generated .d.ts (abbreviated):

declare const _default: {
rarities: {
Common: { color: Color3 };
Rare: { color: Color3 };
Epic: { color: Color3 };
Legendary: { color: Color3 };
};
toilets: {
plain: { displayName: string; rarity: string; multiplier: number };
squeaky: { displayName: string; rarity: string; multiplier: number };
gold: { displayName: string; rarity: string; multiplier: number };
};
crates: {
basic: {
displayName: string;
price: { cash: number };
weights: { plain: number; squeaky: number; gold: number };
};
};
};
export default _default;

The dict-of-records detector recognises Color3.fromRGB(...) as a Color3 constructor, propagates primitive-literal types up the nesting, and keeps per-entry shape names so consumers get autocomplete on Defs.toilets.gold.multiplier.

What gets a real type

The synthesizer captures:

  • Primitive initializer values. Cash = 0Cash: number, Name = "foo"Name: string, Anchored = trueAnchored: boolean.
  • Roblox datatype constructors. Color3.fromRGB(...), Vector3.new(...), CFrame.new(...), UDim2.new(...), etc.
  • List-style arrays of records where every element has the same key set. { {id=1, name="a"}, {id=2, name="b"} }Array<{id: number; name: string}>.
  • Dict-of-records with string-literal keys (above).
  • Method signatures with param types where inferParamPrimitives can prove a primitive constraint from the body (math.floor(n)n: number, s .. "x"s: string, etc.).
  • Signals declared via Instance.new("BindableEvent") + .Event access.
  • Function aliases. M.X = M.Y resolves X to Y's signature.
  • recordMap patterns. M.Profiles = {} followed by M.Profiles[k] = v access emits Profiles: Record<string, defined | undefined>.

Known limits

The synthesizer is intentionally conservative: when it can't prove a type, it falls back to defined rather than guessing.

  • Heterogeneous array-of-records. When a list-style array's elements disagree on key sets (one has resetOnRebirth: true, another omits the field), the whole array falls back to defined. Unioning or making fields optional was explicitly out of scope; wrong types are worse than defined. If you need the shape, refactor the data so every element shares the same keys.
  • Custom signal classes. Only Instance.new("BindableEvent") (plus RemoteEvent, BindableFunction) is detected. Vendored signal libraries (Madwork, Signal.lua, etc.) export their signals as defined rather than misclassifying them as RBXScriptSignal. If you ship a typed wrapper, the consumer can cast.
  • _* fields are not filtered. Luau convention treats underscore-prefixed fields as private, but luau2ts exposes them in the .d.ts. If your module returns a record with internal-only fields, they'll appear on the inferred shape.
  • Non-primitive param types. inferParamPrimitives only proves number / string / boolean. A function M.foo(player) whose body does player:FindFirstChild(...) stays player: unknown. Cross-script param-backprop on call sites can tighten this further but doesn't propagate to the .d.ts today.
  • Return types for methods are always defined. Inferring concrete return types is a planned extension.
  • Cycles. Module A → require B → require A: each module's .d.ts analyses its own body without recursing through requires, so circular dependencies don't cause infinite loops, but they also don't propagate types across the cycle.

Consuming a .d.ts

The declarations work in any TS project that pulls in @rbxts/types (for defined, RBXScriptSignal, Color3, etc.). A hand-written consumer in a Roblox-ts project:

import Defs from "./out/.types/ReplicatedStorage/ToiletDefinitions";

const mult: number = Defs.toilets.gold.multiplier; // typed
const color: Color3 = Defs.rarities.Rare.color; // typed

Without the .d.ts, the consumer would see defined (or unknown) and need explicit casts. With it, the editor surfaces real autocomplete and the type checker catches bad accesses (Defs.toilets.platinum is rejected because platinum isn't a key).