.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/typesglobals likedefinedandRBXScriptSignal). - The script is a
ModuleScript(plain.luau, not.server.luauor.client.luau). Scripts and LocalScripts don't have areturnvalue to type, so no.d.tsis 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 = 0→Cash: number,Name = "foo"→Name: string,Anchored = true→Anchored: 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
inferParamPrimitivescan prove a primitive constraint from the body (math.floor(n)⇒n: number,s .. "x"⇒s: string, etc.). - Signals declared via
Instance.new("BindableEvent")+.Eventaccess. - Function aliases.
M.X = M.YresolvesXtoY's signature. recordMappatterns.M.Profiles = {}followed byM.Profiles[k] = vaccess emitsProfiles: 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 todefined. Unioning or making fields optional was explicitly out of scope; wrong types are worse thandefined. If you need the shape, refactor the data so every element shares the same keys. - Custom signal classes. Only
Instance.new("BindableEvent")(plusRemoteEvent,BindableFunction) is detected. Vendored signal libraries (Madwork, Signal.lua, etc.) export their signals asdefinedrather than misclassifying them asRBXScriptSignal. If you ship a typed wrapper, the consumer can cast. _*fields are not filtered. Luau convention treats underscore-prefixed fields as private, butluau2tsexposes 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.
inferParamPrimitivesonly provesnumber/string/boolean. Afunction M.foo(player)whose body doesplayer:FindFirstChild(...)staysplayer: unknown. Cross-script param-backprop on call sites can tighten this further but doesn't propagate to the.d.tstoday. - 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.tsanalyses 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).