Nothing on this page changes what loom renders. esbuild 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. You are making a second typecheck configuration for 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. Rewriting it to make the preview typecheck breaks the build that ships. Add a separate config for the preview surface.
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 one import declares the
datatypes: UDim2, Color3, Rect, CFrame, TweenInfo, Enum, Instance, game and the
rest. It also declares the Luau environment: task, math, string, os, coroutine, typeIs,
pcall, pairs, tostring, 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 not render. 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. Use ambient module declarations rather than paths — they
resolve through the package’s own exports map, so they do not care how node_modules was laid
out:
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. That errors on a module that does
not already exist, which is why the globals import lives in a separate file.
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 editor and bundler
agree no React import is needed. @rbxts/react re-exports React itself, so the roblox-only extras
React.Event and React.Change are not in its types. A keyed-prop namespace types as an error even
though it works. The Event={{ Activated: fn }} prop form 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: they hard-code a node_modules layout, and reaching into src/client.ts bypasses the package’s exports map. Prefer the declare module form unless you need something the exports map cannot express.
If your preview app is a separate package that imports your UI library by workspace name, the
library itself needs no paths entry. 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.
Your own aliases resolve at runtime too. The preview plugin reads baseUrl and paths out of
the project’s tsconfig.json. A project whose source imports non-relatively, such as
import { Button } from "@/ui/Button", runs.
Typechecking is optional
Since esbuild ignores types, a preview app that never runs tsc still renders. Wiring
tsc --noEmit into CI catches broken scenes early, at the cost of a second tsconfig to maintain.
loom’s own repo typechecks every app, a reasonable default when previews double as documentation.