Loomguides

TypeScript setup

Make the editor happy without breaking your roblox-ts build — ambient globals, JSX intrinsics, path mappings, and why none of it affects what renders.

Nothing on this page changes what loom renders. Vite hands your .tsx to esbuild, which strips types and never reads a tsconfig.json for anything but a couple of transform flags. TypeScript is purely an editor and CI concern here.

That distinction matters because it tells you where the work goes: you are making a second typecheck configuration for browser previewing, not modifying the one that produces Luau.

The ambient globals

roblox-ts code calls UDim2.new(...), Color3.fromRGB(...), Enum.Font.GothamBold and game.GetService(...) with no import. At runtime the preview installs those as real globals. For types, one side-effect import pulls in both the ambient global declarations and the JSX intrinsics:

src/loom-env.d.ts
import "@loom-dev/preview/globals";

Keep the file inside the directory your preview tsconfig includes. That single import declares UDim, UDim2, Vector2, Vector3, Color3, ColorSequence, ColorSequenceKeypoint, Rect, CFrame, TweenInfo, Enum, Instance, game, and the Luau environment: task, tick, math, string, os, coroutine, typeIs, typeOf, pcall, xpcall, pairs, ipairs, tostring, tonumber, error, warn.

Two omissions are deliberate:

  • print is not declared. lib.dom already declares function print(): void, and redeclaring it is a compile error. The runtime still overwrites the value at install time, so print(...) works — it just types as the DOM’s zero-argument version.
  • string shadows the TypeScript string type name. The declaration introduces a global value called string (Luau’s string library), which is legal and does not affect the type. If you see something strange around string, that is why.

A real roblox-ts project gets equivalent declarations from @rbxts/compiler-types instead. You do not want both in the same program.

JSX intrinsics

<screengui>, <frame>, <uilistlayout> and friends come from a declare global { namespace JSX } block in @loom-dev/react, reached transitively through that same globals import. The set is exactly what the renderer implements:

screengui surfacegui billboardgui
frame scrollingframe canvasgroup viewportframe videoframe
textlabel textbutton textbox
imagelabel imagebutton
uilistlayout uigridlayout uitablelayout uipagelayout
uipadding uicorner uistroke uishadow uiscale uigradient
uiaspectratioconstraint uisizeconstraint uiflexitem

An element outside that list is a type error and will also not render — the intrinsic set is exactly what the renderer implements. See Supported instances and properties.

Teaching the editor about @rbxts/*

For the editor to follow @rbxts/react and @rbxts/react-roblox to something real, mirror the plugin’s runtime aliases in types. Do it with ambient module declarations rather than paths — they resolve through the package’s own exports map, so they do not care how your package manager laid out node_modules:

src/loom-aliases.d.ts
declare module "@rbxts/react" {
export * from "react";
export { default } from "react";
}
declare module "@rbxts/react/jsx-runtime" {
export * from "react/jsx-runtime";
}
declare module "@rbxts/react-roblox" {
export * from "@loom-dev/preview/client";
}

Keep this in its own file with no top-level import or export. A .d.ts that has one is a module, and declare module inside a module is an augmentation — which errors on a module that does not already exist. That is why the globals import above lives in a separate loom-env.d.ts.

The matching compiler options:

tsconfig.json for a preview app
{
"compilerOptions": {
"noEmit": true,
"jsx": "react-jsx",
"moduleResolution": "bundler",
"types": ["vite/client", "react"]
},
"include": ["src"]
}

jsx: "react-jsx" matches the esbuild.jsx: "automatic" the plugin sets, so the editor and the bundler agree that no React import is needed. @rbxts/react re-exports React itself because that is what the runtime alias resolves to — the roblox-only extras (React.Event, React.Change) are added by a shim and are not in React’s types, so a keyed-prop namespace will type as an error even though it works. Using the Event={{ Activated: fn }} prop form instead avoids that.

If your preview app is a separate package that imports your UI library by workspace name, no paths entry is needed for the library itself — loom resolves roblox-ts packages to their TypeScript source at runtime, and TypeScript resolves them through the workspace link at check time. The two paths agree.

Typechecking is optional

Since esbuild ignores types entirely, a preview app that never runs tsc still renders. Whether to wire tsc --noEmit into CI for the preview surface is a judgment call: it catches genuinely broken scenes early, and it costs you a second tsconfig to maintain. Loom’s own repo does typecheck every app, which is a reasonable default for a component library whose previews double as documentation.