Loomguides

TypeScript setup

Ambient globals, JSX intrinsics and path mappings — none of which affect what renders.

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.

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 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:

  • 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 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:

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. 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:

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 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.

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.