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

Source: https://docs.astra-void.xyz/loom/guides/typescript-setup/

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.

> **Never point your roblox-ts tsconfig at loom**
>
> 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:

```ts title="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:

```text
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](https://docs.astra-void.xyz/loom/reference/supported-properties.md).

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

```ts title="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:

```json title="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 you prefer `paths`**
>
> Path mappings work too, and were what these docs recommended before:
>
> ```json
> "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.
