# defold-typescript > Human-facing guide for the defold-typescript toolchain. ## Guide ### defold-typescript Build your [Defold](https://defold.com/) game in [TypeScript](https://www.typescriptlang.org/) and get VSCode's full editor experience — autocomplete, inline type errors, and safe refactors — across the whole Defold API, while still shipping the plain [Lua](https://www.lua.org/) the engine runs. - **The full Defold API, typed** — every module and namespace is typed from the official reference, so `go`, `gui`, `vmath`, `msg`, and the rest autocomplete and type-check as you write. - **Compiles to plain Lua** — your TypeScript runs through the battle-tested [TypeScriptToLua](https://typescripttolua.github.io/) (TSTL) compiler down to the Lua Defold already runs: no engine fork, no proprietary runtime, no lock-in. - **Typed scripts end to end** — `self`, `on_message`, and `on_input` payloads are typed through `defineScript`, `defineGuiScript`, and `defineRenderScript`. - **Fits new and existing projects** — scaffold from scratch, or add the TypeScript surface to a project that already has `game.project` and adopt type safety gradually, one script at a time alongside your existing Lua. - **Preserves your project layout** — TypeScript blends into the existing project structure without creating a wrapper folder. - **Built for the real loop** — `watch` recompiles beside the Defold editor, live transpile diagnostics surface errors inline, and source maps let you set breakpoints in your `.ts`. This guide shows how to scaffold a project, write TypeScript that the toolchain compiles to Lua, and look up the language-and-toolchain quirks you will hit along the way. The sections below mirror the top navigation; each lists the pages in its left-sidebar order. The repository `README.md` is generated from this file so the GitHub landing page and docs homepage stay aligned. #### Get started - [Getting started](./getting-started.md) — install Bun, scaffold a new project with `bunx @defold-typescript/cli@latest init my-game`, add TypeScript to an existing Defold project with `init .`, write a one-screen script, and build to Lua with `bunx @defold-typescript/cli build`. - [Editor setup](./editor-setup.md) — open the project in VSCode, use the generated `tsconfig.json`, and run `bunx @defold-typescript/cli watch` beside the Defold editor. - [Defold editor](./defold-editor.md) — install Defold, open the generated project folder, attach a compiled script (`.ts.script`, `.ts.gui_script`, or `.ts.render_script`) to its game object, GUI scene, or render pipeline, and run the game (TypeScript is transpiled to Lua by the CLI, not the editor). #### Guides ##### Tutorial - [Build Tetris](./tetris-tutorial.md) — build a complete Tetris game from scratch: write TypeScript, compile it to Lua, and wire the scene in the Defold editor, seeing the whole workflow end to end. ##### TypeScript - [TypeScript vs Lua](./typescript-vs-lua.md) — the Lua-developer on-ramp: a cheat sheet that translates syntax, tables, modules, and the standard library from Lua to the TypeScript the toolchain expects. - [TypeScript gotchas](./typescript-gotchas.md) — the canonical catalog of TS / [TypeScriptToLua](https://typescripttolua.github.io/) (TSTL) / Defold sharp edges. Today: the unary-minus quirk that silently produces `number` from a `Vector3`. Future entries land here as the toolchain encounters them. - [Data structures](./data-structures.md) — what's built in for Defold: `Array`, tuple, `Map`, `Set`, `WeakMap`, `WeakSet`, object record, and `class`, each with its Lua lowering and `lualib` cost, plus the not-available list (regex, `BigInt`, `LinkedList`) and what to reach for instead. ##### Core concepts - [Script lifecycle](./script-lifecycle.md) — type `self`, `on_message`, and `on_input` payloads with `defineScript`, `defineGuiScript`, and `defineRenderScript`. - [Messages](./messages.md) — the `BuiltinMessages` catalog, `msg.post` send-side payload narrowing, and the `isMessage` / `onMessage` receive-side helpers. - [Where script state lives](./script-state.md) — the four state tiers — per-instance `self`, a shared module local, a cross-script module singleton, and VM-global `declare global` — each grounded in the emitted Lua. - [Vector math](./vector-math.md) — the method-form arithmetic surface (`add`, `sub`, `mul`, `div`, `unm`) on `Vector3`, `Vector4`, `Quaternion`, and `Matrix4`, plus why you cannot write `v3 + v3`. ##### CLI - [init](./init.md) — scaffold a new Defold project with a TypeScript surface, or add TypeScript to an existing project; the two modes, the `--template` / `--force` flags, and the starter templates. - [watch](./watch.md) — the incremental rebuild loop: recompile Lua on every save beside the Defold editor, and re-resolve the extension surface on `game.project` changes. - [build](./build.md) — one-shot transpile of every `src/` TypeScript file to Lua, plus the headless `defold` subcommand that drives `bob` to build and bundle the project. - [wall](./wall.md) — opt-in per-directory API walls that narrow a single-kind source directory to its script-kind surface, in interactive and flag forms. - [resolve](./resolve.md) — generate ambient TypeScript namespaces from your `game.project` native-extension dependencies, with pin/drift detection and a `--frozen` lockfile mode. ##### Toolchain & workflow - [Transpile diagnostics](./transpile-diagnostics.md) — the scaffolded `@defold-typescript/tstl-plugin` language-service plugin that surfaces TypeScript-to-Lua transpile errors live in the editor, advisory-only and never blocking `tsc --noEmit`. - [Debugging](./debugging.md) — step through `.ts` source with breakpoints via the Local Lua Debugger and the scaffolded Bun launch path (no shell, Windows-native), resolving through the emitted `.ts.script.map`. - [Agent runbooks](./agent-runbooks.md) — harness-neutral procedures for driving the CLI from an automated agent: scaffold a project, [install the agent contract](./agent-runbooks.md#install-the-agent-contract), regenerate extension types, [add and attach a script](./agent-runbooks.md#add-a-script) (build, wire the compiled component, verify), and fix the Lua output over the `--json` envelope, gating on `ok`. - [Helper scripts](./helper-scripts.md) — where build, codegen, and maintenance scripts live: a project-root `/scripts` folder run with Bun, typed by their own `scripts/tsconfig.json`, kept off the Lua build path and out of the Defold-typed `src/` surface. ##### Project configuration - [Pinning the Defold version](./pinning-defold-version.md) — keep the default latest surface, or pin an older Defold version whose API surface is generated on the fly and materialized into a project-local `.defold-types//`. - [Native extensions](./extensions.md) — declare an extension in `game.project` `[dependencies]`, then run [`resolve`](./resolve.md) to generate an ambient namespace per `.script_api` into a gitignored `.defold-types/extensions/` surface, and consume it with no import. ##### Migration - [API docs vs `ts-defold-types`](./api-docs-vs-ts-defold.md) — a factual, dimension-by-dimension comparison of the JSDoc that `@defold-typescript/types` and `ts-defold-types` emit (Markdown conversion, dash params, grid-aligned multi-line docs, branded constants, the `@example` trade-off), with a picker for which surface fits your project. - [Migrating from `ts-defold`](./migrating-from-ts-defold.md) — move a project off the `@ts-defold/*` stack: the package/tooling map, the step-by-step port via `init` add-TypeScript mode and a `tsconfig.json` reconcile, and the type-surface differences you will hit. #### Reference - [API](/api) — the generated `@defold-typescript/types` reference: every documented Defold namespace, grouped alphabetically as cards (site-only; built from the typed surface the toolchain ships). - [Lua standard library](/api/base) — the pure-Lua / LuaJIT reference category (`base`, `bit`, …). Types are owned by the `lua-types` dependency the `lua-stdlib-globals` goal adopted; `@defold-typescript/types` does not re-emit them as generated namespaces. - **For AI agents** — machine-readable docs per [llmstxt.org](https://llmstxt.org/): * [`llms.txt`](https://defold-typescript.github.io/toolchain/llms.txt) is the map (start here) * [`llms-full.txt`](https://defold-typescript.github.io/toolchain/llms-full.txt) is the full corpus (grep it, never read it whole). The same pair ships into a consumer's `node_modules/@defold-typescript/docs/` on install. ### Getting started Scaffold a Defold project with TypeScript sources, write a script, and build to Lua. The whole loop runs on Bun — no `npm` or `node`. Coming from Lua? See [TypeScript vs Lua](./typescript-vs-lua.md) for a translation cheat-sheet. Coming from ts-defold? See [Migrating from ts-defold](./migrating-from-ts-defold.md). #### Install Bun Follow the official instructions at [bun.sh](https://bun.sh/). Verify: ```sh bun --version ``` You need Bun `>= 1.3`. #### Scaffold a project The package is scoped, so run it through `bunx` by its full name — no install required: ```sh bunx @defold-typescript/cli@latest init my-game cd my-game git init bun install ``` > `my-game` will be created if it doesn't exist. > > **Optional**: Run `bunx @defold-typescript/cli@latest init-agents .` to initialize `AGENTS.md` and `CLAUDE.md` files. ```sh bunx @defold-typescript/cli@latest init . git init bun install ``` > `.` scaffolds into the folder you are already in. `init` writes a minimal Defold project (`game.project`, `main/main.collection`, `input/game.input_binding`) alongside a TypeScript surface (`src/main.ts`, `tsconfig.json`, `package.json`). `game.project` boots the collection from its `[bootstrap]` section and points `[input]` at the binding, so a fresh scaffold loads in Defold with no missing references. The collection points at the generated `src/main.ts.script`, so the TypeScript starter is the script Defold runs. See [Init](./init.md) for more options. > [!NOTE] `init` requires an explicit destination — there is no implicit "current folder" default, so it never scaffolds where you did not mean to. Pass a path to create (or add to) that folder, or `.` to target the folder you are already in. > [!TIP] `init` also generates a `mise.toml`. If you already have [mise](https://mise.jdx.dev) installed, it flags the new file as untrusted — run `mise trust` once to approve it. > After that, `mise run` (or its shorthand `mise r`) lets you pick one of the scaffolded tasks interactively. Use the `@latest` tag when you scaffold: `bunx` caches binaries, and `init` is what writes your `@defold-typescript/types` version pin, so a stale cache would pin an older release. `@latest` always scaffolds against the current version. Check which version you are running with `-v` / `--version`: ```sh bunx @defold-typescript/cli --version ``` #### Add TypeScript to an existing project `init` is not just for fresh directories. Run it inside an existing Defold project to drop in the TypeScript infrastructure (`package.json`, `tsconfig.json`, `.gitignore`, `biome.json`, editor settings, and managed tasks) without touching `.script`, `.collection`, `.gui_script`, `.render_script`, `game.project`, or other engine assets. 1. `cd` into the project, such as a clone of [`defold/template-platformer`](https://github.com/defold/template-platformer). 2. Run `bunx @defold-typescript/cli@latest init .` (the `.` targets the current folder). 3. Run `bun install`, matching the install reminder printed by `init`. 4. Hand-convert your Lua scripts to TypeScript — move each Defold lifecycle hook into a `defineScript({...})` method; the [platformer example](https://github.com/defold-typescript/toolchain/tree/main/docs/examples/platformer) is a worked conversion. 5. Run `bunx @defold-typescript/cli build` to transpile the converted sources. 6. Open the project in the Defold editor and Build-and-Run. #### Update `@defold-typescript` version To update to the latest version: ```sh bunx @defold-typescript/cli@latest init . --force ``` Despite the intimidating name, `--force` does not overwrite your source files — `src/main.ts`, `.script`, `.collection`, `game.project`, and other engine assets are left alone. It only refreshes the managed config (`tsconfig.json`, `biome.json`, and the `@defold-typescript` dependency pins), so an existing project keeps up with the current toolchain. #### Project structure `@defold-typescript` doesn't create any folder wrappers around your Defold project, and instead adds TypeScript infrastructure in-place. The scaffold declares its `devDependencies` in `package.json`: * `@defold-typescript/types` for the editor's ambient Defold types * `@defold-typescript/cli` pinned to the same version so the build runs in lockstep with those types * `@defold-typescript/tstl-plugin` for the live transpile diagnostics * `@biomejs/biome` for lint and format * `@types/bun` for the `.vscode/` debug launcher * `lua-types` for the ambient Lua standard library so `math`/`string`/`table`/… resolve `bun install` is what actually downloads these dependencies to the `node_modules` folder (it is gitignored inside the generated `.gitignore`). If you forget to do so, your editor reports the Defold globals as unresolved. > [!WARNING] If you are using VSCode, you may need to open the `main.ts` file and run `Restart TS Server` from the Command Palette, or simply reload the window if the errors persist even after installing all the dependencies. > [!TIP] `init` does not install for you (that keeps scaffolding offline), so > it prints a `Next: run install` reminder once it finishes, picking the > package manager from the runner that invoked it (`bun`/`npm`/`pnpm`/`yarn`, > falling back to `bun`). > > Pass `--suppress-install-reminder` to silence that line when you install through your own tooling. The scaffold also ships an opinionated `biome.json`, so the project lints and formats cleanly out of the box. An existing `biome.json` is left untouched, unless you pass `--force`, which migrates the one deprecated Biome `recommended` key and leaves your other settings in place. If the scaffolded `@defold-typescript/types` pin still looks older than the CLI you expect, your `bunx` cache is stale — `@latest` above already forces the current release, which is more reliable than clearing the cache with `bun pm cache rm`. #### Write a script By default, only `.ts` files under the project's `src/` folder are compiled. That scope is the `include` array in `tsconfig.json` (both `build` and `watch` honor it) — widen it to manage more folders: ```json { "include": ["src/**/*.ts"] } ``` Open `src/main.ts` and replace its body with: ```ts import { defineScript } from "@defold-typescript/types"; export default defineScript({ properties: { name: hash("player"), }, init(self) { // self is the property channel here, not the merged self the other hooks see. const start = vmath.vector3(0, 0, 0); const offset = vmath.vector3(1, 1, 0); const target = start.add(offset); print(`target: ${target.x}, ${target.y}, ${target.z}`); return { target }; }, }); ``` `defineScript` is the one import — it wires `init` (and the other lifecycle hooks) to Defold's script table. `vmath`, `print`, and the `Vector3` shape are ambient globals, so they need no import. > [!NOTE] > Inside `init`, `self` is the property channel. In every other hook, `self` widens to the union of the property channel and whatever `init` returns. #### Build to Lua ```sh # one-time build bunx @defold-typescript/cli build # rebuild on every change while you edit bunx @defold-typescript/cli watch ``` [`build`](./build.md) transpiles every TypeScript file under `src/` to Lua and writes the result into the Defold project tree — each source becomes exactly one output: a lifecycle-factory file becomes a Defold script component (`src/main.ts` -> `src/main.ts.script`), a plain module becomes a Lua module (`src/util.ts` -> `src/util.lua`). Open the project in the [Defold editor](./defold-editor.md) to play it, or build and run it headlessly — see [Build](./build.md) for the full behavior, the generated-file markers, and the headless `defold` subcommand. While you edit, run [`watch`](./watch.md) instead: it rebuilds incrementally on every save and keeps the native-extension surface current (run [`resolve`](./resolve.md) once first). See [code editor setup](./editor-setup.md) for the VSCode and integrated-terminal loop. ### Code editor setup Open the Defold project folder in VSCode or any editor with TypeScript support. Use the folder that contains `game.project`, `src/`, and `tsconfig.json`. #### What `tsconfig.json` provides The generated `tsconfig.json` sets: - `types: ["@defold-typescript/types"]` so Defold globals such as `vmath`, `msg`, and `print` type-check in `src/*.ts`. - no `outDir`, so generated Lua lands next to its `.ts` source. Lifecycle-factory files become Defold components (`src/main.ts` -> `src/main.ts.script`), while helper-only imports become Lua modules (`src/util.ts` -> `src/util.lua`). Set a concrete `outDir` to collect the outputs under a separate tree instead. - `strict: true` so project code gets normal TypeScript checks. VSCode's built-in TypeScript support reads this file automatically when the project folder is open. #### Hover documentation The generated type declarations carry the Defold reference docs inline as JSDoc. Hovering a Defold symbol in `src/*.ts` shows its description straight from the engine API docs — functions also list each parameter (`@param`) and their return (`@returns`), plus a worked `@example` code block when the engine docs ship one; constants, variables, and component `properties` show a summary line. The core consumer-facing modules (`vmath`, `go`, `msg`, `sprite`) render their `@example` blocks as idiomatic TypeScript — see [TypeScript vs Lua](typescript-vs-lua.md) for the idiom mapping — while the remaining modules fall back to the engine's Lua sample until translated. No extra extension required, since it rides on VSCode's built-in TypeScript support. Autocomplete surfaces the same text. Undocumented engine symbols simply show their signature with no description. Hover docs reach beyond the generated signatures: the overloaded facades (`msg.post`, `go.get`/`go.set`) and the hand-authored helpers (`isMessage`, `onMessage`) carry the same summary + `@param` + `@returns` + `@example` blocks, so the receive-side message helpers hover as richly as the generated namespace API. #### Recommended editor extensions You edit TypeScript, not Lua, and the build runs from the CLI — so the required extension stack is minimal: - TypeScript support: built into VSCode. This is what type-checks `src/*.ts` against `@defold-typescript/types`; no extra extension needed. - [Local Lua Debugger](https://marketplace.visualstudio.com/items?itemName=tomblind.local-lua-debugger-vscode) (`tomblind.local-lua-debugger-vscode`) — **required for debugging**: steps through your `.ts` source via the emitted source maps. See [Debugging](debugging.md) for the launch path. > [!WARNING] > **Defold Kit (`astronachos.defold`) is not needed.** This toolchain ships its own ambient Defold types and a CLI build loop, so the Kit's annotation sync is redundant — and its setup wizard overwrites files this toolchain manages. `init` no longer recommends it. Defold runs standard Lua 5.1 (LuaJIT), **not** Luau. If you want to read the *generated* Lua, [sumneko Lua](https://marketplace.visualstudio.com/items?itemName=sumneko.lua) (`sumneko.lua`) adds a Lua language server — optional, and no longer auto-recommended, since you author in TypeScript. > [!WARNING] > Avoid the Roblox **Luau Language Server** (`johnnymorganz.luau-lsp`) in a Defold project: it reports `Failed to load sourcemap.json` and bogus diagnostics, because it expects a Roblox/Rojo `sourcemap.json` — an artifact this toolchain never produces. The `*.ts.script.map` files next to your output are [TypeScriptToLua](https://typescripttolua.github.io/) (TSTL) source maps, unrelated to Roblox's `sourcemap.json`. `init` scaffolds a `.vscode/` folder that encodes this: - `extensions.json` recommends only Local Lua Debugger and marks the Luau LSP as unwanted. - `settings.json` sets `Lua.workspace.ignoreDir: ["src"]` so that, *if* you install the optional sumneko Lua server, it does not lint the generated `*.ts.script` output (which would flag TSTL-emitted `self` parameters as unused). Hand-written Defold `.script` files under `main/` stay analyzed. The setting is harmless when sumneko is absent. - `defold-typescript.code-snippets` expands an empty script over the lifecycle factories (the TypeScript equivalent of the Defold editor's "new script" templates). - `launch.json` + `defold-debug.ts` set up a shell-free, Windows-native debug launch path. See [Debugging](debugging.md). - `tasks.json` registers `defold-typescript: build` / `defold-typescript: watch` tasks with a shared problem matcher, so transpile errors land in the editor's Problems panel. These files merge additively into any `.vscode/` config you already have, so your own recommendations, settings, snippets, and launch configs are preserved. #### Script snippets In any `.ts` file, type one of these prefixes and accept the completion to scaffold a whole empty script over `defineScript` / `defineGuiScript` / `defineRenderScript`: | Prefix | Scaffolds | | ----------------------------------------- | ----------------------------------------- | | `def-ts-defineScript-inferred-self` | script, state inferred from `init` | | `def-ts-defineScript-typed-self` | script, explicit `Self` type | | `def-ts-defineGuiScript-inferred-self` | GUI script, state inferred from `init` | | `def-ts-defineGuiScript-typed-self` | GUI script, explicit `Self` type | | `def-ts-defineRenderScript-inferred-self` | render script, state inferred from `init` | | `def-ts-defineRenderScript-typed-self` | render script, explicit `Self` type | The two variants mirror the two self-typing idioms (see [Script lifecycle](script-lifecycle.md)). The inferred variant returns an inline object from `init`, and `TSelf` follows that return — the documented default. The typed variant declares a named `Self` type and passes it explicitly as `defineScript(…)`, the escape hatch for when you want to name the state shape up front. Render snippets omit `on_input`, which `RenderScriptHooks` does not expose. ##### Keeping snippets and types in sync An expanded snippet emits every lifecycle hook the installed `@defold-typescript/types` declares. If your editor reports ` does not exist in type 'ScriptHooks'` (and the parameters of that method turn implicitly `any`), work through it in this order: 1. **Restart the TypeScript server first.** A stale language-server process keeps the old type surface in memory after a dependency upgrade, so the error survives even once the right types are on disk. In VS Code, run *TypeScript: Restart TS Server* from the command palette. This clears most false positives. 2. **Then bring the types up to the CLI.** The snippet emits whatever the *CLI* knows about, so a newer hook (such as `fixed_update` or `late_update`) errors when the project's resolved `@defold-typescript/types` lags the CLI that wrote the snippet. Upgrade to the latest and keep them in lockstep: `bunx @defold-typescript/cli@latest init . --force` repins the managed `@defold-typescript/types` dependency to the CLI's own version. Edit TypeScript under `src/`. Treat generated `.ts.script`/`.ts.gui_script`/`.ts.render_script` component files, helper `.lua` modules, and their `.map` siblings next to your sources as build output; the scaffolded `.gitignore` keeps them out of version control. #### Run the watch loop Keep Defold open on the same project folder, and in the editor's integrated terminal run [`watch`](./watch.md): ```sh bunx @defold-typescript/cli watch ``` It rebuilds Lua when files under `src/` change; run the game from the Defold editor after each rebuild. See [Watch](./watch.md) for the incremental-session behavior, the extension-surface reconciliation, and the `mise` task equivalent (also listed below). #### Opinionated `mise.toml` `init` scaffolds (or additively merges into) an opinionated `mise.toml` with the following tasks. The block is marker-fronted, so re-running `init` refreshes the managed tasks without disturbing your own `[tools]` or `[tasks.*]` entries. - **`defold-typescript:build`** — `bunx @defold-typescript/cli build`. Builds once with the project's CLI. - **`defold-typescript:watch`** — `bunx @defold-typescript/cli watch`. The watch loop above, as a task. - **`defold-typescript:resolve`** — `bunx @defold-typescript/cli resolve`. Materializes the native-extension and vendored-library type surfaces from `game.project` dependencies. `watch` runs this automatically on every `game.project` change, so it is mainly a one-off after adding a dependency. - **`defold-typescript:setup-debug`** — `bunx @defold-typescript/cli setup-debug`. Wires the lldebugger `game.project` dependency and entry-script bootstrap (see [Debugging](debugging.md)). - **`defold-typescript:init-agents`** — `bunx @defold-typescript/cli init-agents .`. Materialize or refresh the `AGENTS.md` / `CLAUDE.md` AI-harness contract. - **`defold-typescript:upgrade`** — `bunx @defold-typescript/cli@latest init . --force --suppress-install-reminder` then `bun install` (the second command reinstalls, so the install reminder is suppressed). The deliberate upgrade path. Build and watch carry no version tag, so inside an installed project `bunx` resolves the `@defold-typescript/cli` that `init` scaffolds as a pinned devDependency — the build runs the version locked alongside the pinned `@defold-typescript/types`. Upgrade is the one task that intentionally pulls `@latest`: `init . --force` re-pins both managed deps to the new CLI's version, and `bun install` reinstalls. > **Upgrading from a pre-`init .` project.** `init` now requires an explicit destination, but a `mise.toml` scaffolded by an older CLI carries a `:upgrade` task that calls `init --force` with no path. The first `mise run defold-typescript:upgrade` therefore fails with `a destination folder is required`. Run `bunx @defold-typescript/cli@latest init . --force` once by hand: it rewrites the managed `mise.toml` blocks (the refresh runs on every `init`, not just `--force`) so the dotted `:upgrade` task lands on disk and the task works from then on. ### Defold editor Install the Defold editor from [defold.com](https://defold.com/). The editor is the engine UI you use to open the project folder, inspect assets, and run the game. In the CLI-driven loop you author code in TypeScript and build from the command line (`defold build`, see [Build](build.md#headless-builds-no-editor)) — so the editor is opened mainly for **visual assets**: collections, atlases, tilemaps, GUI scenes, and previewing the running game. Compiling and running can happen entirely from the CLI without it. #### Open the project 1. Start Defold to open the launcher. (If the editor is already open, choose **File** → **Open Project** to bring the launcher back up.) 2. On the launcher, click **Open From Disk...** and select the `game.project` file in your project folder. For a project created with `bunx @defold-typescript/cli@latest init my-game`, that is the destination folder you named — the same folder where you run the CLI commands. #### Build before running Run the TypeScript build before launching the game: ```sh bunx @defold-typescript/cli build # or keep it running while you edit: bunx @defold-typescript/cli watch ``` By default the scaffolded `tsconfig.json` has no `outDir`, so generated Lua lands next to its `.ts` source. Lifecycle-factory files — those whose `export default` is one of these factories — become Defold script components, ready to be attached. | Source factory | Compiled artifact | Referenced by | | -------------------- | ------------------------- | -------------------------------------------------------------- | | `defineScript` | `.ts.script` | a game object (`.go` / `.collection`) as a component | | `defineGuiScript` | `.ts.gui_script` | a GUI scene (`.gui`), as its **Script** property | | `defineRenderScript` | `.ts.render_script` | the render pipeline (a `.render` file, set via `game.project`) | A source exporting no lifecycle factory compiles to a Lua module (`src/util.ts` -> `src/util.lua`) — a generated artifact you import through the `.ts` and never edit or reference by hand. Keep generated output up to date with `build` or `watch` while you work. Set a concrete `outDir` if you prefer the outputs collected under a separate tree. #### Attach a script to a game object A built script does nothing on its own. `src/main.ts` compiles to `src/main.ts.script`, but it runs only once it is added to a game object as a component — a new script that "does nothing" is almost always unattached, and its `properties` stay inert until it is a live component instance. In the editor, select a game object (in a `.go` file or a collection), add the compiled `.ts.script` as a component, and Build. Defold writes the `component: "/src/….ts.script"` reference for you; you always point at the compiled `.ts.script`, never the `.ts` source. GUI and render scripts attach the same way — through a `.gui` scene's **Script** field and the render pipeline; see [Script lifecycle](./script-lifecycle.md#api-availability-by-script-kind) for which factory produces which kind. Driving this without the editor — editing `.go` / `.collection` text directly and verifying the attachment from the command line — is the agent path: [Add a script](./agent-runbooks.md#add-a-script). #### Run the game With the project open in Defold, press **Build** or **Project > Build** to run the game. If you change TypeScript code, rebuild with `bunx @defold-typescript/cli build` before running again, or keep `watch` running in a terminal. ### TypeScript vs Lua A translation cheat sheet for Defold developers who already know Lua. Lua on the left, the TypeScript you write on the right. It is a map, not a tutorial: skim the tables, port your mental model, and reach for the linked pages when a detail bites. For the runtime traps the type system cannot catch — `0` and `""` being truthy, `nil` collapsing `null` and `undefined`, `typeof` not narrowing engine handles — see [TypeScript gotchas](./typescript-gotchas.md). This page only flags those at cheat-sheet depth and links down. #### Syntax at a glance | Concept | Lua | TypeScript | | ---------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Line comment | `-- note` | `// note` | | Block comment | `--[[ … ]]` | `/* … */` | | Bind a local | `local x = 1` | `const x = 1` (never reassigned), `let x = 1` (reassigned) | | Not equal | `a ~= b` | `a != b` or `a !== b` — identical Lua (see the [gotchas page](./typescript-gotchas.md#-and--compile-to-the-same-lua--strictness-is-a-convention-not-a-runtime-guard)) | | Equal | `a == b` | `a == b` or `a === b` — identical Lua (see the [gotchas page](./typescript-gotchas.md#-and--compile-to-the-same-lua--strictness-is-a-convention-not-a-runtime-guard)) | | Logical and / or / not | `and` / `or` / `not` | `&&` / `\|\|` / `!` | | String join | `"a" .. b` | `"a" + b`, or a template literal `` `a${b}` `` | | Length | `#t` | `t.length` | | Block delimiters | `then … end`, `do … end` | `{ … }` | | Absence | `nil` | `null` and `undefined` (both lower to `nil` — see the gotchas page) | | Index base | 1-based: `t[1]` | 0-based: `arr[0]` | Two things to internalise before the rest of the page: - **Equality has no loose/strict split here.** `==` / `!=` and `===` / `!==` compile to the *same* non-coercing Lua `==` / `~=`, so JavaScript's coercion — the thing `===` guards against — never happens in the output. The scaffolded `biome.json` ships `noDoubleEquals` off, so neither form lints; use whichever reads best — the worked examples here (such as the Tetris build) use `==` / `!=` to mirror Lua's `==` / `~=`. Both are value equality for primitives and reference equality for objects — the same split Lua draws between numbers/strings and tables. The one real equality trap is `if (cell)` truthiness, since `0` is truthy in Lua — see the [gotchas page](./typescript-gotchas.md#-and--compile-to-the-same-lua--strictness-is-a-convention-not-a-runtime-guard). - **Indexing flips from 1 to 0.** This is the single biggest porting bug. A Lua `for i = 1, #t` loop becomes a `for (let i = 0; i < arr.length; i++)` loop, and every literal index shifts down by one. Prefer `for…of` (below) so you never touch the index at all. #### Tables vs objects, arrays, and Maps Lua has one container — the `table` — used for records, arrays, and dictionaries alike. TypeScript splits that one type into three, each with its own syntax and methods. Pick the one that matches how you actually use the data: | Lua `table` used as… | TypeScript | Notes | | ------------------------------ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | Record / struct | object literal `{ x: 1, y: 2 }` | fixed, named string keys | | Record with a computed key | object literal `{ [graphics.BUFFER_TYPE_COLOR0_BIT]: params }` | bracketed key from an expression — Lua's `[expr] = v` (e.g. `render.render_target` option tables keyed by engine enum constants) | | Sequence / list | array `[1, 2, 3]` | 0-based; `arr.length`, `arr.push(x)` | | Dictionary with arbitrary keys | `Map` | `new Map()`, `.set(k, v)`, `.get(k)`; non-string keys | Iteration translates the same way: | Lua | TypeScript | | --------------------------- | ------------------------------------------------ | | `for _, v in ipairs(t) do` | `for (const v of arr)` | | `for k, v in pairs(t) do` | `for (const [k, v] of Object.entries(obj))` | | `for k, v in pairs(map) do` | `for (const [k, v] of map)` (or `map.entries()`) | Do **not** use `for…in` to walk an array — TypeScriptToLua rejects it because JavaScript `for…in` iterates keys in an unspecified order that differs from Lua. Use `for…of` for values, `Object.entries` / `Map.entries` for pairs. Under the hood TypeScriptToLua still stores arrays in 1-based Lua tables; you write 0-based TypeScript and the transpiler emits the offset. The one place the abstraction leaks is sparse arrays: setting an element to `null`/`undefined` or leaving holes can make `arr.length` and Lua's `#` disagree, so keep arrays dense. Beyond these three, TypeScript also gives you `Set`, `WeakMap`, `WeakSet`, and `class`, and rejects a few things outright (regex, `BigInt`). For the full map of what is built in, what each lowers to, its `lualib` cost, and what to reach for when something is missing, see [Data structures](./data-structures.md). #### Modules: `require` vs `import` Lua wires files together with `require` and a returned table. TypeScript uses `import` / `export`, and TypeScriptToLua lowers them straight back onto Lua's module system — an `import` becomes a `require`, and your `export`s become the module's returned table. | Lua | TypeScript | | ------------------------------------------ | ---------------------------------------------- | | `local M = {}` … `return M` | `export function f() {}`, `export const C = …` | | `local foo = require("foo")` | `import { f, C } from "./foo"` | | `local foo = require("foo")` (whole table) | `import * as foo from "./foo"` | Use **relative** specifiers (`"./foo"`, `"../lib/util"`) for your own files under `src/`. Engine APIs are different: the namespaces `go`, `msg`, `vmath`, `sprite`, `gui`, `render`, and the rest ship from `@defold-typescript/types` as **ambient globals**, so you call `vmath.vector3(…)` or `msg.post(…)` with no import at all. You only `import` your own modules. #### File structure and script mapping You edit TypeScript under `src/`; the toolchain emits Lua beside each source by default. Files that call lifecycle factories become Defold-loadable components, and helper-only files become Lua modules for imports: ```text src/main.ts → src/main.ts.script src/util.ts → src/util.lua ``` Defold resolves a resource by the extension after its last dot, so a `.ts.script` file is a valid `.script` component the engine loads directly — the `.ts` in the name only marks its TypeScript origin. `src/util.lua` is a plain Lua module whose path matches the `require("src.util")` emitted for `import "./util"`. Run `bunx @defold-typescript/cli build` once or `bunx @defold-typescript/cli watch` to keep outputs current. Lua scripts attach behaviour by defining bare global callbacks (`function init(self)`, `function on_input(self, action_id, action)`). In TypeScript you type those through `defineScript` instead, which gives `self` and the message and input payloads real types. See [script lifecycle](./script-lifecycle.md) for the full surface and the per-kind API walls. #### Standard library and built-ins This is where Lua and TypeScript diverge most, because they ship different standard libraries. TypeScriptToLua targets the **ECMAScript** feature set: when you write idiomatic TypeScript — array methods, string methods, `Math`, template literals — the transpiler emits the matching Lua via its runtime library. So the idiomatic move is to use the TypeScript form, not to call the Lua global. | Lua | Idiomatic TypeScript | | ------------------------------ | ----------------------------- | | `table.insert(t, x)` | `arr.push(x)` | | `#t` | `arr.length` | | `string.format("%d", n)` | template literal `` `${n}` `` | | `tostring(x)` | `` `${x}` `` or `String(x)` | | `tonumber(s)` | `Number(s)` | | `string.sub`, `string.find`, … | `str.slice`, `str.indexOf`, … | | `math.abs`, `math.floor`, … | `Math.abs`, `Math.floor`, … | A caveat worth knowing: `Array.prototype.sort` lowers to Lua's `table.sort`, which is **not stable**, unlike JavaScript's guaranteed-stable sort. If element order among equal keys matters, sort on a tiebreaker. The raw Lua standard library — the `math`, `os`, `string`, `table`, and `coroutine` tables plus base globals like `pairs`, `ipairs`, `pcall`, `print`, `tostring`, `type`, `assert`, and `setmetatable` — **is** part of the ambient surface: `@defold-typescript/types` references the `lua-types` package, so these type-check and autocomplete with no import. Defold's own `hash()` is ambient too and returns `Hash`. Reach for a local `declare global` only for genuinely Lua/Defold-specific globals the type package does not cover. A `declare global` block is type-only: it **emits no Lua**. The first assignment to the declared name is what creates the global at runtime, and it lowers to a bare VM-wide Lua global — no `local`, no module prefix. Given `declare global { var FOO: number }`, the use site `FOO = FOO + 1` compiles to exactly `FOO = FOO + 1`. That global is shared across the entire VM, broader than a `require`-cached module local, so for ordinary app state prefer a module singleton — see [Where script state lives](./script-state.md) for the full placement picture. Two of Lua's basic types deserve a note because Defold leans on them. **Userdata** is arbitrary C data stored in a Lua variable — Defold uses it for hashes, URLs, the math objects (`vector3`, `vector4`, `matrix4`, `quaternion`), game objects, GUI nodes, render predicates, render targets, and constant buffers. You never name `userdata` in TypeScript: each one surfaces as a distinct branded type (`Hash`, `Url`, `Vector3`, `Vector4`, `Matrix4`, `Quaternion`, …) so the compiler stops you mixing a hash with a vector or a plain table. **Threads** are independent execution contexts and back Lua coroutines; the ambient `coroutine` table (from `lua-types`) creates and resumes them and returns a `LuaThread`. Coroutines work, but for frame-paced waiting prefer Defold's own `timer.*` / `go.animate` scheduling. Prefer the idiomatic-TypeScript column above wherever it exists. The case where you must reach for the Lua global is **random numbers**. Defold's RNG is deterministic until seeded, and the only way to seed it is the Lua call: ```ts math.randomseed(os.time()); const roll = math.random(1, 6); // integer in [1, 6] ``` `Math.random()` and `math.random(m, n)` are not interchangeable. `Math.random()` returns a `[0, 1)` float ([TypeScriptToLua](https://typescripttolua.github.io/) (TSTL) lowers it to a Lua runtime helper) and cannot be seeded; `math.random(m, n)` returns an integer in `[m, n]` from the seedable engine RNG. Use the Lua form whenever you need a reproducible or integer-ranged result. The transpiler targets **Lua 5.1** to match Defold's runtime (LuaJIT on native and desktop, a 5.1 VM on HTML5). That keeps the emitted code clear of 5.4-only constructs — integer division `//`, bitwise operators, `goto`, the two-argument `math.randomseed` — which the engine would reject. The ambient `math.randomseed` is correspondingly single-argument, so the two-argument form is a type error, not a runtime surprise. #### Libraries - **Your own code** is just more TypeScript files — `import` them by relative path. No registration step, no manifest. - **npm packages** work only if they transpile to self-contained Lua. A package that touches Node.js or browser built-ins (`fs`, `process`, `window`, `fetch`) will not run on the Defold Lua VM, because TypeScriptToLua implements the ECMAScript standard library and nothing host-specific. - **Engine features** come from the ambient `@defold-typescript/types` namespaces (`go`, `msg`, `vmath`, …), never from npm. There is no package to install for them; they are part of the types surface the scaffold pins. #### See also - [TypeScript gotchas](./typescript-gotchas.md) — the runtime sharp edges this page only points at: truthiness, `nil` collapse, `typeof`, opaque handles. - [Script lifecycle](./script-lifecycle.md) — typing `self`, `on_message`, and `on_input` with `defineScript`. - [Where script state lives](./script-state.md) — per-instance `self`, shared module locals, module singletons, and the VM-global `declare global` lowering. - [Data structures](./data-structures.md) — the full container availability map: `Array`, tuple, `Map`, `Set`, `WeakMap`, `WeakSet`, object record, and `class`, plus what is not available and what to use instead. - [Vector math](./vector-math.md) — why `v3 + v3` is not allowed and you use `v3.add(other)` instead. - [Getting started](./getting-started.md) — scaffold, write a script, build to Lua. ### TypeScript gotchas Sharp edges where TypeScript, the TypeScriptToLua transpiler, or the Defold runtime behave in ways the type system cannot fully express. Each entry names the symptom, explains why it happens, and gives the recommended workaround. New to TypeScript from Lua? Start with [TypeScript vs Lua](./typescript-vs-lua.md) for the syntax-and-idiom map; this page is the depth catalog you reach for when one of those quirks bites. #### Before you start: Lua vs TypeScript gotchas The one-line version of every trap on this page. Skim it once; jump to the full entry when one bites. - [Unary minus on a vector yields `number`](#unary-minus-on-vector3--vector4-silently-produces-number) — `-v3` infers `number`, not `Vector3`; use `v.unm()`. - [Enum constants are branded numbers](#enum-constants-are-branded-numbers--a-bare-number-wont-do) — a bare magic number won't satisfy an enum parameter; pass the named `go.PLAYBACK_*` constant. - [Engine handles are opaque](#engine-handles-are-opaque--you-cannot-fabricate-or-cast-across-kinds) — a `node` is not a `texture`; obtain each handle from the API that returns it, never fabricate or cross-cast. - [Callback parameters are `unknown`](#callback-parameters-type-check-as-functions-not-unknown) — match the documented arity; narrow each param before use. - [Some slots are `unknown` on purpose](#some-slots-are-unknown-on-purpose--the-any-wildcard) — `socket.skip` and friends accept anything and force a narrow on the way out. - [`if (x)` truthiness differs in Lua](#if-x-truthiness-differs--0-and--are-truthy-in-lua) — `0` and `""` are **truthy** once transpiled; test the value explicitly. - [`typeof` cannot narrow engine values](#typeof-cannot-narrow-engine-values--they-are-lua-userdata) — Defold handles are Lua `userdata`, so `typeof x === "object"` is `false` for them at runtime. - [`on_message` ids are hashes, not strings](#on_message-ids-are-hashes-not-strings) — compare `message_id` against a `hash("…")` constant; a string literal never matches at runtime. - [`window.set_listener` hands `event` and `data` separately](#windowset_listener-hands-event-and-data-as-separate-params) — TS can't auto-narrow `data` from an `event` check; use the `isWindowEvent` guard. - [`null`/`undefined`/`== null` all become `nil`](#null-undefined-and--null-all-collapse-to-nil) — the three TS forms collapse to one Lua check; you cannot tell them apart at runtime. - [`===` and `==` are the same Lua](#-and--compile-to-the-same-lua--strictness-is-a-convention-not-a-runtime-guard) — both lower to a non-coercing Lua `==`; `==` is not JS loose equality here, and the real equality footgun is `if (cell)`. - [`as` is not a runtime check](#as-is-a-compile-time-assertion-not-a-runtime-check) — a cast erases to nothing; the value is unverified at runtime. - [Component properties are catalogued per namespace](#component-properties-are-catalogued-per-namespace) — read property types off `label.properties["color"]`, not off the namespace object. - [`async`/`await` work but there is no event loop](#asyncawait-work-but-there-is-no-event-loop) — a `Promise` only advances when something resolves it synchronously; the importable `@defold-typescript/types/timers` polyfills bridge Defold's `timer.delay` so `await wait(s)` resumes on a later frame. - [URL addressing: same-world is relative, `socket:` crosses worlds](#url-addressing-same-world-objects-are-relative-socket-crosses-worlds) — a sibling instance is `"camera"` or `"/camera"`, a sibling component is `"#name"`; `socket:` names a target world by its collection `Name` and is reserved for crossing into a collection-proxy-loaded world. #### Unary minus on Vector3 / Vector4 silently produces `number` **Symptom.** `-v3a` compiles cleanly and infers as `number`, not `Vector3`. Anything downstream that expects a `Vector3` (passing to `go.set_position`, chaining `.add(...)`, etc.) fails to compile with a confusing error pointing at the *use site*, not at the `-`. ```ts declare const v3a: Vector3; const neg = -v3a; // inferred as `number`, not `Vector3` const target: Vector3 = -v3a; // TS error here — but the bug is the `-` ``` **Why.** TypeScript's TS2362 check ("operand of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum") applies to *binary* `+`, `-`, `*`, `/` but **not** to *unary* `-`. The unary form has its own narrow check that does not reject object-typed operands; it just produces `number`. There is no way to opt a type out of unary negation at the type-system level, so the `vector3` interface cannot defend itself. **Typed alternative.** Use `v.unm()` — the same metamethod the Lua runtime would call for `-v`, but exposed as a typed method: ```ts const neg: Vector3 = v3a.unm(); // returns Vector3 ``` The same gap exists for `Vector4`; the same workaround applies: `v.unm()`. **How we pin this in the type tests.** `packages/types/test-d/guide-snippets.ts` carries a symmetric proof: one line asserts `const _: number = -v3a` (which must compile), the next asserts `const _: Vector3 = -v3a` with `@ts-expect-error` (which must remain an error). If a future change either fixes the TS behavior or breaks the workaround, exactly one of the two lines flips and the typecheck gate fails. #### Enum constants are branded numbers — a bare number won't do **Symptom.** A call that the Lua runtime accepts with a magic number fails to typecheck when the parameter is an enum: ```ts go.animate(id, "position.x", 2, 10, go.EASING_LINEAR, 1); // TS error on `2` go.animate(id, "position.x", go.PLAYBACK_ONCE_FORWARD, 10, go.EASING_LINEAR, 1); // ok ``` **Why.** Defold's ref-doc encodes an enum member as a bare constant and lists the members' fully-qualified names as a parameter's accepted types. The generated typings recover each constant as a *nominally branded* number — `go.PLAYBACK_ONCE_FORWARD` has type `number & { readonly __brand: "go.PLAYBACK_ONCE_FORWARD" }` — and the parameter's type is the union of those brands. A plain `number` lacks the brand, so only a real `go.PLAYBACK_*` (or `go.EASING_*`) value satisfies it. This is intentional strictness: it stops you from passing the wrong magic number. **Typed alternative.** Always pass the named constant (`go.PLAYBACK_ONCE_FORWARD`, `go.EASING_LINEAR`, …). The brand is type-only and erases at transpile time to a plain Lua global access, so there is no runtime cost. A branded constant is still assignable to any plain `number` parameter, so existing numeric signatures keep working. **How we pin this in the type tests.** `packages/types/test-d/guide-snippets.ts` asserts the constant is assignable to `number` (backward compatibility) and to its own brand, while a bare `0` assigned to the brand carries `@ts-expect-error`. If the brand is dropped or widened, the expected error disappears and the typecheck gate fails. **Constants can live in another namespace.** The brand is keyed on the constant's fully-qualified name, not on the namespace of the function that accepts it, so an enum can be owned by one namespace and consumed by another. The render buffer-type flags are the worked example: `render.get_render_target_width`, `render.get_render_target_height`, and `render.enable_texture` take a `graphics.BUFFER_TYPE_*` flag (`graphics.BUFFER_TYPE_COLOR0_BIT` … `_COLOR3_BIT`, `graphics.BUFFER_TYPE_DEPTH_BIT`, `graphics.BUFFER_TYPE_STENCIL_BIT`), and the named constant lives in the `graphics` namespace. ```ts render.get_render_target_width(rt, graphics.BUFFER_TYPE_COLOR0_BIT); // ok render.get_render_target_width(rt, 0); // TS error on `0` ``` `packages/types/test-d/graphics-render-buffer-types.ts` pins this: the real `graphics` constant type-checks and a bare number carries `@ts-expect-error`. #### Engine handles are opaque — you cannot fabricate or cast across kinds **Symptom.** A GUI `node`, a render `texture` or `render_target`, a `buffer`, a `resource`, a box2d `b2World`/`b2Body`, and similar engine handles each have a distinct nominal type. A value obtained for one kind is not accepted where another kind is expected, and a plain value (a number, an object literal) is never accepted: ```ts const n = gui.get_node("button"); // Opaque<"node"> render.enable_texture(0, n); // TS error — a node is not a texture render.set_render_target(undefined); // TS error — undefined is not a render_target ``` **Why.** Defold's ref-doc names these handles by a bare token (`node`, `texture`, `buffer`, …) with no public structure. The generated typings recover each as `Opaque<"">` — a phantom-branded type whose brand is the token string. Distinct tokens carry distinct brands, so they are mutually non-assignable, and because the brand is a `unique symbol` property no plain value satisfies it. This mirrors the `Hash`/`Url` brands: the strictness stops you from passing a handle of the wrong kind or conjuring one from thin air. **Typed alternative.** Obtain the handle from the engine API that returns it (`gui.get_node`, `render.render_target`, `resource.create_buffer`, …) and thread that value through. There is no constructor and no meaningful cast across handle kinds — if you find yourself casting, the call you are feeding it almost certainly wants a different handle. **Named handle types.** Each handle is also referenceable as a named type alias under its namespace — `render.render_target`, `render.constant_buffer`, `render.texture`, `socket.master`, `socket.client`, `socket.unconnected`, `b2d.b2World`, `b2d.b2Body` — so you can annotate a binding directly (`const rt: render.render_target = render.render_target("rt", {})`). The alias resolves to the same `Opaque<"">` brand the handle-returning function yields, so the annotation and the returned value are the same nominal type. **How we pin this in the type tests.** `packages/types/test-d/ambient.ts` passes an `Opaque<"constant">`-typed value to `model.play_anim` and asserts a bare `0` in the same slot carries `@ts-expect-error`. It also binds `render.render_target(...)` to a `render.render_target`-typed variable and asserts a bare `0` cannot be — proving the named alias and the function's return type share one brand. If the brand is dropped or widened to `unknown`, the expected errors disappear and the typecheck gate fails. #### Callback parameters type-check as functions, not `unknown` **Symptom.** Engine APIs that take a callback (`timer.delay`, `msg`-style message handlers, `http.request`, factory/particlefx completion callbacks, …) now expect a function of a specific arity. Passing a non-function, or a function with the wrong number of parameters, is a type error: ```ts timer.delay(0.5, false, () => print("tick")); // too few params — TS error timer.delay(0.5, false, (self, handle, time_elapsed) => { }); // OK — three params ``` **Why.** Defold's ref-doc names a callback parameter by its whole Lua signature string, e.g. `function(self, handle, time_elapsed)`. The generated typings recover each into an arity-preserving function type with the documented parameter names: `(self: unknown, handle: unknown, time_elapsed: unknown) => void`. The parameter names survive for hover documentation, but their types are `unknown` — the ref-doc carries no inner types — so you must narrow each argument yourself before using it. The return is `void`: the engine ignores any value a callback returns, and a `void` return position still accepts a callback that happens to return something. **Typed alternative.** Match the documented arity and read the parameter names off hover to know what each slot is. Cast or narrow the `unknown` params at the point of use (`const url = result as Url`), exactly as you would any other `unknown`. #### Some slots are `unknown` on purpose — the `any` wildcard **Symptom.** A few API slots are typed `unknown`: they accept any value going in, and a value coming out cannot be used until you narrow it. ```ts const [first] = socket.skip(1, "a string", 42); // first is `unknown` const n: number = first; // TS error — narrow it first const n2 = first as number; // OK ``` **Why.** Where Defold's own ref-doc types a value as the bare `any` token — "any Lua value" — the generated typings use `unknown` rather than guessing a concrete type. `socket`'s utilities (`socket.skip`) and its option accessors (`getoption`/`setoption`) are the cases you will meet. This is deliberate, not a gap: `unknown` is the honest model for "could be anything." It accepts everything as an argument, and as a result it forces a check before use — unlike TypeScript's `any`, which would silently switch type-checking off for that value. **Typed alternative.** Treat these exactly like callback parameters: narrow or cast at the point of use (`first as number`), guided by what the Defold API docs say the value is. A branded or nominal type would be wrong here — the whole point of the `any` token is that no single concrete type fits. **How we pin this in the type tests.** `packages/types/test-d/socket-any-unknown.ts` passes assorted values into `socket.skip`'s wildcard slots (proving any argument is accepted) and asserts a wildcard return is not directly assignable to `number` via `@ts-expect-error`, while a narrowing cast is. If a wildcard slot ever widened to `any` or were mis-mapped to a concrete type, the expected error would flip and the typecheck gate would fail. #### `if (x)` truthiness differs — `0` and `""` are truthy in Lua **Symptom.** A guard that reads naturally in TypeScript changes meaning after transpile. `if (count)` in TS is false for `0`; the Lua it becomes is true for `0`. ```ts function describe(count: number, label: string): string { if (count) { // transpiles to `if count then` — true even when count is 0 return label; } return "empty"; } ``` **Why.** `if (x)` transpiles to `if x then`, a direct pass-through with no coercion. JavaScript and Lua disagree on what counts as falsy: JS treats `0`, `""`, `NaN`, `null`, and `undefined` as falsy; Lua treats **only** `nil` and `false` as falsy. So `0` and `""` — falsy in the TypeScript you wrote — are **truthy** in the Lua that runs. The type system cannot warn you: the code is well-typed in both directions. **Typed alternative.** Test the value you actually mean. Use `if (count > 0)`, `if (label !== "")`, or `if (x !== undefined)` instead of relying on truthiness. An explicit comparison transpiles to the same comparison in Lua and means the same thing in both languages. **How we pin this in the type tests.** `packages/transpiler/src/narrowing-transpile.test.ts` snapshots `transpile(...)` of an `if (x)` guard and asserts the committed Lua is `if x then` — no coercion inserted. If [TypeScriptToLua](https://typescripttolua.github.io/) (TSTL) ever started wrapping the condition in a JS-truthiness helper, the snapshot would change and the gate would fail. #### `typeof` cannot narrow engine values — they are Lua `userdata` **Symptom.** A `typeof` guard that should match a Defold handle never fires at runtime, even though it type-checks. ```ts function asNode(x: unknown): unknown { if (typeof x === "object") { // transpiles to `type(x) == "table"` — false for a gui.node return x; } return undefined; } ``` **Why.** `typeof x === "object"` transpiles to `type(x) == "table"`. Defold engine values — `gui.node`, `texture`, `buffer`, `b2World`, … — are Lua `userdata`, not tables, so `type(handle)` returns `"userdata"` and the branch is skipped. The value form is no better: `typeof x` transpiles to `__TS__TypeOf(x)`, which returns the string `"userdata"` for these handles — a value TypeScript's `typeof` union (`"object"`, `"function"`, `"number"`, …) does not even contain, so you cannot write a matching comparison. `typeof x === "number"/"string"/"boolean"/"function"` *do* inline to a reliable `type(x) == "…"`, and those work as written; it is only objects-vs-userdata that breaks. **Typed alternative.** Do not reach for `typeof` to recognise an engine handle. The handles are already nominally typed (`Opaque<"node">` and friends — see [Engine handles are opaque](#engine-handles-are-opaque--you-cannot-fabricate-or-cast-across-kinds)), so thread the typed value through instead of re-checking it. When you genuinely hold an `unknown` from a wildcard slot, narrow it with the engine predicate that fits (a `nil` check, a field probe) rather than `typeof`. The one runtime narrowing the engine *does* support for vmath userdata is the `types.is_*` family, emitted as user-defined type guards: ```ts declare const v: unknown; if (types.is_vector3(v)) { // v is Vector3 here — v.x is a number, no cast needed const x = v.x; } ``` `types.is_hash`, `types.is_url`, `types.is_matrix4`, `types.is_quat`, `types.is_vector`, `types.is_vector3`, and `types.is_vector4` each narrow their argument to the matching core type. The other `is_*` / `exists` names (`go.exists`, `sound.is_music_playing`, …) are runtime-state checks on an already-typed value, not type guards, so they stay `boolean`. **How we pin this in the type tests.** `packages/transpiler/src/narrowing-transpile.test.ts` snapshots both forms: `typeof x === "object"` → `type(x) == "table"`, and the value form `typeof x` → `__TS__TypeOf(x)`. The committed Lua makes the userdata mismatch visible; if either lowering changed, the snapshot would fail. #### `on_message` ids are hashes, not strings **Symptom.** A handler that compares `message_id` against a string literal never fires, even though it reads naturally and (under older typings) compiled. The object it should react to is silently ignored — in the platformer example, the player fell through every wall because the contact handler never ran. ```ts on_message(self, message_id, message) { if (message_id == "contact_point_response") { // never true at runtime // ... } } ``` **Why.** Defold delivers `message_id` to `on_message` as a pre-hashed `hash` value, not a string — the same way `action_id` arrives in `on_input`. Lua's `==` does no cross-type coercion, so `hash_value == "contact_point_response"` is always `false`. The string comparison transpiles verbatim (`message_id == "contact_point_response"`) and silently never matches. `ScriptHooks.on_message` types `message_id` as `Hash` to make the mismatch a *compile* error rather than a runtime mystery; the string-literal form no longer type-checks. **Typed alternative.** Pre-hash the id once at module scope and compare `Hash` to `Hash`, exactly as the original Lua does and as `on_input` already does for `action_id`: ```ts const msg_contact_point_response = hash("contact_point_response"); on_message(self, message_id, message) { if (message_id == msg_contact_point_response) { const contact = message as unknown as ContactPoint; // handler payload is an untyped record // ... } } ``` The handler's `message` is an untyped `Record` — cast it to the subset you read. Sender-side payload narrowing by message id lives on `msg.post` (see the [messages guide](./messages.md)), not on the handler. **How we pin this in the type tests.** `packages/types/test-d/lifecycle.ts` binds `message_id` to a `Hash` inside the handler and asserts a string-literal `message_id` at the call site carries `@ts-expect-error`. If `on_message` ever re-typed the id as a string, the expected error would disappear and the typecheck gate would fail. #### `window.set_listener` hands `event` and `data` as separate params **Symptom.** A `window.set_listener` callback reads the resize dimensions off `data`, but `data.width` does not type-check (or is `unknown`) even after checking `event == window.WINDOW_EVENT_RESIZED`: ```ts window.set_listener((self, event, data) => { if (event == window.WINDOW_EVENT_RESIZED) { print(data.width, data.height); // data stays a record — width/height not known } }); ``` **Why.** The engine delivers `event` and `data` to the callback as two **separate** parameters, and the `WINDOW_EVENT_*` constants are branded numbers. TypeScript never correlates two independent parameters, and a branded number is not a unit-type discriminant — so an `event == …` check cannot auto-narrow `data` the way a discriminated-union field would. The generated callback types `event` as the union of the five `WINDOW_EVENT_*` constants (so you get autocomplete and simple positive `event ==` narrowing) and `data` as a bare `Record` (only a resize carries fields, so the record is the honest default). **Typed alternative.** Use the provided `isWindowEvent` guard — the window mirror of [`isMessage`](messages.md#receiving-messages-with-type-narrowing). It re-introduces the discriminant at the use site and narrows the untyped `data` to that event's payload: ```ts window.set_listener((self, event, data) => { if (isWindowEvent(event, data, window.WINDOW_EVENT_RESIZED)) { print("resized:", data.width, data.height); // data: { width: number; height: number } } }); ``` `isWindowEvent` is declaration-only: the transpiler lowers the call to a bare `event == window.WINDOW_EVENT_RESIZED`, so there is no runtime function and no import to add. Only `WINDOW_EVENT_RESIZED` carries fields; every other event narrows `data` to `undefined`. This is the same wall — and the same fix — as [`on_message` ids](#on_message-ids-are-hashes-not-strings): the engine hands back an untyped payload discriminated by a separate id/event argument, and a declaration-only guard is the path to a typed payload. #### `null`, `undefined`, and `== null` all collapse to `nil` **Symptom.** Three TypeScript checks you might think are distinct produce one identical Lua test, so they cannot be told apart at runtime. ```ts function present(x: unknown): boolean { if (x === null) return false; // `x == nil` if (x === undefined) return false; // `x == nil` return true; } ``` **Why.** Lua has a single absent value, `nil`. TypeScript's `null`, `undefined`, the strict `x === null` / `x === undefined`, and the loose `x == null` all transpile to `x == nil`. There is no Lua-level distinction between "explicitly null" and "missing", so a function that tries to treat `null` and `undefined` differently cannot — both arrive as `nil`. The `=== nil` form (using the ambient `nil` type) works as written and is the honest model. **Typed alternative.** Model absence with a single sentinel. Pick `undefined` in your TypeScript (it is what the engine's `nil` maps to most naturally), use `x === undefined` or `x == null` to test it, and do not design APIs that depend on distinguishing the two. If you need a third "unset" state, encode it explicitly with a value, not with `null`-vs-`undefined`. **How we pin this in the type tests.** `packages/transpiler/src/narrowing-transpile.test.ts` snapshots `x === null` and `x === undefined` side by side and asserts both committed lines are `x == nil`. The collapse is visible in the snapshot; if [TypeScriptToLua](https://typescripttolua.github.io/) (TSTL) ever distinguished them, the gate would fail. #### `===` and `==` compile to the same Lua — strictness is a convention, not a runtime guard **Symptom.** Coming from JavaScript you might assume `==` performs loose equality — coercing its operands before comparing — and reach for `===` to avoid it. In this toolchain the two operators are indistinguishable in the output, so the coercion `===` guards against never happens. ```ts function isEmpty(cell: number): boolean { const strict = cell === 0; // → `cell == 0` const loose = cell == 0; // → `cell == 0` (byte-identical) return strict || loose; } ``` **Why.** TypeScriptToLua emits Lua `==` for **both** operators and never inserts a coercion helper, and Lua `==` is already non-coercing. The one runtime distinction JavaScript draws between the two — loose equality's coercion, including `== null` matching both `null` and `undefined` — is moot here as well, because `null` and `undefined` both collapse to `nil` (see [`null`, `undefined`, and `== null` all collapse to `nil`](#null-undefined-and--null-all-collapse-to-nil)). So `===` buys no runtime safety the `==` form lacks; strictness is a source convention, not a guard. Contrast `if (x)` truthiness (see [`if (x)` truthiness differs](#if-x-truthiness-differs--0-and--are-truthy-in-lua)), which *is* a real change of meaning crossing into Lua. **Typed alternative.** The scaffolded `biome.json` ships with `noDoubleEquals` off precisely because the operators are identical in the Lua output, so use whichever reads best — `===` is no longer enforced for consumers. (Our repo keeps the rule on as house style; its `== null` exemption also preserves one genuinely useful loose form — `x == null` narrows out both `null` and `undefined` in TypeScript's type-checker, a *compile-time* distinction even though the emitted Lua is identical.) The real equality footgun is never `==` versus `===`; it is `if (cell)`, which is truthy for `0` once it runs as Lua. **How we pin this in the type tests.** `packages/transpiler/src/narrowing-transpile.test.ts` transpiles `cell === 0` and `cell == 0` in one snippet and asserts both lower to the same `cell == 0` — the loose form gets no coercion helper. If [TypeScriptToLua](https://typescripttolua.github.io/) (TSTL) ever started coercing `==`, the two lines would diverge and the gate would fail. #### `as` is a compile-time assertion, not a runtime check **Symptom.** A cast that compiles cleanly does nothing at runtime — the value is never validated, and a wrong assertion produces a silent type mismatch later, not an error at the cast. ```ts function first(values: unknown): number { const n = values as number; // transpiles to `local n = values` — no check return n + 1; // runs even if `values` was a string or a table } ``` **Why.** `const n = first as number` transpiles to `local n = first`: the cast is erased entirely. TypeScript's `as` is a *compile-time* assertion that you, the author, know the type — it inserts no runtime guard. So a cast that is wrong does not throw at the cast site; the bad value flows on until some Lua operation on it misbehaves, far from the cause. This is the same erasure that makes branded constants and opaque handles zero-cost — useful there, a footgun when you use `as` to "convert" an `unknown`. **Typed alternative.** When you actually need to *check* a value (a wildcard `unknown`, untrusted input), narrow it with a real runtime test — `typeof x === "number"` (reliable for primitives, see the `typeof` entry above), a `=== nil` check, or a field probe — before using it. Reserve `as` for the cases where you already know the type from context the type system can't see, exactly as with callback and wildcard params. **How we pin this in the type tests.** `packages/transpiler/src/narrowing-transpile.test.ts` snapshots `const n = x as number` and asserts the committed Lua is `local n = x` with no inserted check. If a cast ever started emitting a guard, the snapshot would change and the gate would fail. #### Component properties are catalogued per namespace **Symptom.** Defold component properties — the values you address at runtime through `go.get`/`go.set`/`go.animate` with a string id (`"color"`, `"position"`, `"size"`, …) — do not appear as members on the namespace object. There is no `label.color` you can read directly. **Why.** These properties are not module-level globals; they are per-component fields keyed by a string id passed to `go.get(url, "color")`. The ref-doc lists them as `PROPERTY` elements with a name and a type but no addressable symbol. The generated typings recover each namespace's properties into an in-namespace `interface properties { … }` so the name and type survive as a committed, drift-gated catalog. For example, `label.properties` carries `color: Vector4`, `size: Vector3`, `scale: number | Vector3`, and so on. **Typed alternative.** Read a property's type off the catalog with an indexed access — `label.properties["color"]` is `Vector4`. The catalog also makes `go.get`/`go.set` type-aware: a literal key drawn from `go`'s own transform properties narrows the result and the written value to the property's real type. `go.get(url, "position")` is `Vector3`, `go.get(url, "scale")` is `number`, and `go.set(url, "euler", v3)` type-checks while `go.set(url, "position", "oops")` is rejected. Any other key — a `Hash`, a dynamic string, or a fragment-addressed cross-component property like `go.get("#sprite", "tint")` — falls through to an open `string | Hash` overload returning the documented wide union, so hashed and runtime access keep working unchanged. **Name the component to narrow a cross-component property.** A string url like `"#sprite"` cannot tell the type system which component it targets, so the bare call can only narrow `go`'s own transform properties. When you know the component, name it with a type argument and the result narrows to *that* component's catalog: ```ts const animation = go.get()("#sprite", "animation"); // Hash, not the wide union go.set()("#sprite", "playback_rate", 2); // value gated to number go.get()("#sprite", "nope"); // type error — not a sprite property ``` The form is curried — `go.get

()(url, property)` — on purpose. TypeScript has no partial type-argument inference: if `P` and the property key were both type parameters on one call, supplying `` would force the key to its default and collapse the return to the component's whole value union. The empty `()` fixes `P`, then the inner call infers the key from the `"animation"` argument, recovering the exact field type. You are naming the component yourself — nothing checks that `"#sprite"` actually resolves to a sprite — so this is a typed convenience over an unverified url, not a correctness guarantee. The bare direct call (`go.get("#go", "position")`) is unchanged for the transform default, and a fully dynamic key still falls through to the wide union. **Name the script to read or tune another object's declared properties.** The same generic also types the *cross-script* channel — reading or writing a property a *different* script declared, addressed by its script-component url (`go.get("/enemy#controller", "speed")`). This is distinct from a component property and from the `self`-side access inside the owning script: it is how one object tunes another object's declared state. A script declares its editor properties with the value-keyed `properties` field of `defineScript`, and exports that shape as a nameable type with `ScriptPropertiesOf`; another script names it as the `P` generic to get a typed read and a value-gated write: ```ts // enemy.ts — the owning script declares its properties export default defineScript({ properties: { speed: 100, target: vmath.vector3() }, }); export type EnemyProps = ScriptPropertiesOf; // elsewhere — another object reads and tunes them by url const speed = go.get()("/enemy#controller", "speed"); // number go.set()("/enemy#controller", "speed", 250); // value gated to number go.get()("/enemy#controller", "missing"); // type error — not declared ``` `ScriptPropertiesOf` extracts the same `TProps` the owning script's `self` carries, so the cross-script type and the owning script's `self` stay one source of truth — there is no second hand-maintained interface to drift. As with the component generic, you name the script yourself; nothing verifies the url resolves to that script. **How we pin this in the type tests.** `packages/types/test-d/guide-snippets.ts` asserts `label.properties["color"]` is assignable to `Vector4` and that a `string` annotation on the same member carries `@ts-expect-error`. `packages/types/test-d/go-property-accessors.ts` pins the typed `go.get`/`go.set`: narrow keys resolve to `Vector3`/`Quaternion`/`number`, a wrong-typed `go.set` value and a wrong-typed `go.get` assignment carry `@ts-expect-error`, and the `Hash`/dynamic-string fallback still type-checks. `packages/types/test-d/go-property-access.ts` pins the caller-named-component generic: `go.get()("#sprite", "animation")` is `Hash`, a wrong key and a wrong value type carry `@ts-expect-error`, and the bare transform call still resolves; it also pins the cross-script channel — a `ScriptPropertiesOf`-named read is typed, a write is value-gated, and a wrong key or wrong value type carries `@ts-expect-error`. `packages/types/test-d/lifecycle.ts` proves `ScriptPropertiesOf` extracts exactly the declared property shape (excess and wrong-typed fields carry `@ts-expect-error`), tying it to the `self` channel. If the property catalog is dropped, its member types change, or the overloads regress, one of these lines flips and the typecheck gate fails. #### Field-documented tables are typed object shapes, not opaque records **Symptom.** A `table` param or return whose Defold ref-doc spells out its fields, or whose official example proves a stable shape, is no longer the opaque `Record` it once was. `sys.get_sys_info()` returns a concrete object — `system_name`, `gmt_offset`, and the rest are typed members you can read directly and that auto-complete; a misspelled field is a compile error, not silent `unknown`. ```ts const info = sys.get_sys_info(); const name: string = info.system_name; // typed string, no cast const offset: number = info.gmt_offset; // typed number // @ts-expect-error not_a_field is not a field of the sys info table info.not_a_field; ``` **Why.** Many Defold `table` slots carry their field structure only in the doc HTML, in one of four regular shapes: a `

field
T
` definition list, a `