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.
A roblox-ts project’s tsconfig.json is consumed by rbxtsc and must keep describing the Roblox
environment: @rbxts/compiler-types, @rbxts/types, jsxFactory, typeRoots, the whole thing. If
you rewrite it to make the preview typecheck, you break the build that actually ships. Add a separate
config for the preview surface instead.
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:
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:
printis not declared.lib.domalready declaresfunction print(): void, and redeclaring it is a compile error. The runtime still overwrites the value at install time, soprint(...)works — it just types as the DOM’s zero-argument version.stringshadows the TypeScriptstringtype name. The declaration introduces a global value calledstring(Luau’s string library), which is legal and does not affect the type. If you see something strange aroundstring, 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 billboardguiframe scrollingframe canvasgroup viewportframe videoframetextlabel textbutton textboximagelabel imagebuttonuilistlayout uigridlayout uitablelayout uipagelayoutuipadding uicorner uistroke uishadow uiscale uigradientuiaspectratioconstraint uisizeconstraint uiflexitemAn 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:
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:
{ "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.
Path mappings work too, and were what these docs recommended before:
"baseUrl": ".","paths": { "@rbxts/react": ["./node_modules/@types/react"], "@rbxts/react-roblox": ["./node_modules/@loom-dev/preview/src/client.ts"]}They are more fragile in two ways: they hard-code a node_modules layout (fine for a direct
dependency under pnpm, not guaranteed in general), and reaching into src/client.ts bypasses the
package’s exports map, which is where the published type entry points actually live. Prefer the
declare module form unless you need a mapping the exports map cannot express.
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.