Skip to content

Synced from the transformer README.

TypeTorch logo

@typetorch/transformer ​

The roblox-ts transformer behind TypeTorch. It does three things at compile time:

  1. User macros. A function tagged @metadata macro gets its omitted trailing parameters filled in from types: runtime t guards, stable type ids, call-site info. Nested and mapped types work, so createNetwork<C2S, S2C>() gets one guard per method of a nested interface.
  2. Class metadata for dependency injection. Classes decorated with a TypeTorch decorator get an identifier, their constructor's dependency ids (typetorch:parameters) and a Reflect.decorate call.
  3. Debug and build-info macros. $print, $warn, $error, $assert, $dbg, $nameof, $file, $package, $git and $compileTime from @typetorch/transformer/macros: what rbxts-transform-debug did, in this transformer (0.3.2+), so a project needs one plugin (and one type check) instead of two. See Debug macros.

It is a stripped-down fork of rbxts-transformer-flamework (MIT), with the guard builder and macro engine kept as they are. TypeTorch projects don't depend on Flamework at all: the runtime (Reflect, Modding) lives in @typetorch/framework, and the source of truth for it is runtime-kit/.

The transformer runs in the game's compile. @typetorch/framework only declares the macros and decorators and compiles without it (it lists @typetorch/transformer as an optional peer dependency); the macros expand where a game calls them.

Install ​

sh
npm i @typetorch/framework
npm i -D @typetorch/transformer

(bun add @typetorch/framework and bun add -d @typetorch/transformer work the same.) The transformer runs inside rbxtsc: it needs roblox-ts 3 and the typescript it uses (5.5), which every roblox-ts project already has. The runtime its generated code imports (Reflect, Modding, t) is @typetorch/framework; nothing from Flamework is needed.

tsconfig.json of a game (TypeTorch payloads are roblox-ts Model projects):

jsonc
{
	"compilerOptions": {
		"experimentalDecorators": true,
		// @typetorch must be a type root so roblox-ts accepts imports from @typetorch/framework.
		"typeRoots": ["node_modules/@rbxts", "node_modules/@typetorch"],
		"plugins": [{ "transform": "@typetorch/transformer" }]
	}
}

In the Rojo project, map only the runtime package, not the whole scope (the transformer is Node code and would add empty folders to the model):

json
"@typetorch": { "$className": "Folder", "framework": { "$path": "node_modules/@typetorch/framework" } }

The package ships a dependency-free out/index.d.ts, because TypeScript loads every package under a type root as a type library.

Options ​

Passed in the plugin entry: { "transform": "@typetorch/transformer", "runtimeModule": "..." }.

OptionDefaultMeaning
runtimeModule"@typetorch/framework"Module generated code imports Reflect from (and t, see below).
runtimeReflectPath"reflection/reflect"Only when the runtime package itself compiles with the transformer (package name equals runtimeModule; the test fixture's stand-in does, the real framework doesn't): the rootDir-relative file exporting Reflect. Generated code imports it relatively, since a package can't import itself by name.
guardModuleautomaticModule generated guards import t from. By default @rbxts/t when the file resolves the same copy as the runtime package, otherwise t re-exported by runtimeModule.
debugMacrostrueWhether $print, $warn, $error, $assert and $dbg compile their output and source positions in. TYPETORCH_DEBUG_MACROS=0 in the environment turns them off whatever this says (the TypeTorch CLI sets it for prod builds). See Debug macros.
semanticDiagnosticsfalseType-check every file before transforming it and skip files with errors. roblox-ts already checks the transformed program, so this only guards the transformer against ill-typed input, and it type-checks the project a second time (about 9 s of a 40 s cold build on a 400-file game). noSemanticDiagnostics is the deprecated inverse.
optimizations.guardGenerationDedupLimitoffTypes used this many times inside one guard are hoisted into a local and reused.

When the transformer resolves a different typescript than roblox-ts (for example when it is linked), its require("typescript") is pointed at roblox-ts' copy while it loads, the same trick Flamework uses. Set TYPETORCH_TRANSFORMER_HOOK=0 to disable that, or =1 to force it outside a project folder. rbxtsc --verbose makes the transformer log more.

User macros ​

Declare a macro with the JSDoc tag @metadata macro. Optional trailing parameters whose types are macro types are filled in when the caller leaves them out (or passes undefined).

ts
import { Modding } from "@typetorch/framework";

/** @metadata macro */
export function guardOf<T>(guard?: Modding.Generic<T, "guard">) {
	assert(guard);
	return guard;
}

/** @metadata macro */
export function idOf<T>(id?: Modding.Generic<T, "id">) {
	assert(id);
	return id;
}

const isPoint = guardOf<{ x: number; y: number }>(); // guardOf(t.interface({ x = t.number, y = t.number }))
const id = idOf<ShopService>(); // idOf("server/services/shop@ShopService")
TypeFilled with
Modding.Generic<T, "guard">a t guard for T
Modding.Generic<T, "id">the stable id of T (see Ids)
Modding.Generic<T, "text">T printed as a string
Modding.GenericMany<T, "id" | "guard">an object with the requested keys
Modding.Caller<"line" | "character" | "width" | "text" | "uuid">info about the call site (uuid is random per compile)
Modding.CallerMany<...>an object with the requested keys
Modding.TupleLabels<T>the labels of tuple T, e.g. parameter names via Parameters<F>
Modding.Many<X>X built recursively: objects, tuples, arrays, string/number/boolean literals and undefined, with macro types as leaves

Modding.Many follows mapped and recursive types, which is how TypeTorch's networking gets one guard per leaf:

ts
type GuardTree<T> = {
	[K in keyof T]: T[K] extends (...args: infer A) => unknown ? Modding.Generic<A, "guard"> : GuardTree<T[K]>;
};

/** @metadata macro */
export function createNetwork<C2S extends object, S2C extends object>(
	clientToServer?: Modding.Many<GuardTree<C2S>>,
	serverToClient?: Modding.Many<GuardTree<S2C>>,
) { /* ... */ }

interface ClientToServer {
	shop: { buy(itemId: string, amount: number): void; inventory: { equip(slot: 1 | 2 | 3, itemId?: string): void } };
}
createNetwork<ClientToServer, {}>();

compiles to

lua
local network = createNetwork({
	shop = {
		buy = t.strictArray(t.string, t.number),
		inventory = {
			equip = t.strictArray(t.literalList({ 2, 1, 3 }), t.optional(t.string)),
		},
	},
}, {})

A parameter can accept either a value or a macro: id?: string | Modding.Generic<T, "id"> fills in the id only when the caller passes nothing.

Macros work across packages: a macro declared in @typetorch/framework is recognised from its .d.ts, so packages must keep JSDoc in their declaration output (don't set removeComments).

Guards ​

The guard builder is Flamework's, unchanged. Highlights:

TypeGuard
string, number, boolean, undefined/void, any, unknownt.string, t.number, t.boolean, t.none, t.any, t.union(t.any, t.none)
literals, literal unions, TS enumst.literal(...) / t.literalList({...})
T | undefined, optional propertiest.optional(...)
unions, intersectionst.union / t.unionList, t.intersection / t.intersectionList
tuples (Parameters<F>)t.strictArray(...)
T[], Map<K, V>, Set<T>, index signaturest.array, t.map, t.set, t.map(key, value)
objects and interfacest.interface({...})
Roblox datatypes (Vector3, CFrame, Color3, ...), Enum.X, buffert.Vector3, ..., t.enum(Enum.X), t.typeof("buffer")
Instance typest.instanceIsA("BasePart"), plus t.children for declared children
functionst.callback

Not supported (a compile error points at the type): template literal types, classes, and more than one index signature.

Network leaves (@metadata macro network) ​

A macro tagged @metadata macro network (the framework's createNetwork and createFlameworkCompat) declares network leaves: every Modding.Generic<A, "guard"> inside its Modding.Many trees is one leaf's parameter list, named by its dotted path. For those, the transformer also reports what a guard can't do, at the leaf's declaration:

LeafDiagnostic
no guard possible (a class, a template literal type, ...)error Network leaf "a.b": no guard can be generated for its parameters, with the original reason and the supported parameter types
generic or conditional signature (<T extends Model | undefined>(box: T, group: T extends Model ? string : undefined) => void)warning: the guard checks each type parameter as its constraint and a conditional type as either branch, so how the arguments go together isn't enforced. Use a union of tuples ((...args: [box: Model, group: string] | [box: undefined]) => void) or separate leaves
a parameter (or a field inside one) that never arrives: a function, thread, RBXScriptSignal, RBXScriptConnection, an AnimationTrackwarning: Roblox sends nil, so a client -> server guard refuses every message and the other direction gets nil

Supported parameter types: string, number, boolean, undefined/optional, literals and TS enums, Enum items, Roblox datatypes, Instances by class, arrays, tuples, Maps, Sets, plain objects/interfaces, unions, buffer, and unknown/any. Warnings don't stop the build; the leaf keeps its (loose) guard. Older transformers ignore the network key.

Debug macros ​

ts
import { $print, $warn, $error, $assert, $dbg, $nameof, $file, $package, $git, $compileTime } from "@typetorch/transformer/macros";

Transformer 0.3.2 and later. The transformer expands every call and drops the import (nothing in macros.d.ts exists at runtime), so the module never reaches roblox-ts. The names and output are those of rbxts-transform-debug 2.2, which TypeTorch projects used before; calls through that package's own declarations are expanded too, so a project can remove the rbxts-transform-debug plugin from its tsconfig first and rewrite the imports later (then remove the package). Not carried over: $dbg's custom handler and $keysof (unimplemented upstream).

MacroCompiles to
$print(...), $warn(...)print("[src/file.ts:12]", ...), warn(...) likewise
$error(message, level?)error("[src/file.ts:12] " .. message, level)
$assert(condition, message?)assert(condition, "[src/file.ts:12] <condition text>: " .. message) (message defaults to Assertion failed!)
$dbg(x)as a statement print("[src/file.ts:12] x =", x); as an expression an IIFE that prints and returns x
$nameof<T>(), $nameof(a.b)"T", "b" (an identifier, a property name, a string, or this inside a class)
$file.filePath, $file.lineNumber, $filethe file's path relative to the project (forward slashes), the line, or both as an object
$package.version, $packagea field of the project's package.json as a literal, or the whole file
$git(), $git("Branch", "Commit")an object literal: Branch, Commit (7 characters), CommitHash, LatestTag, ISODate and Timestamp (author date, seconds), or the named fields. Read with the git CLI once per compile; "" without a repository. $git().Branch compiles only that field.
$compileTime(), $compileTime("DateTime" | "UnixTimestampMillis" | "ISO-8601")the compile time: unix seconds by default, else DateTime.fromIsoDate("...")!, milliseconds or an ISO string

The first five are the debug macros. With debugMacros: false in the plugin options, or TYPETORCH_DEBUG_MACROS=0 in the environment, $print/$warn/$dbg compile to nothing (a $print statement vanishes, an expression is undefined, $dbg(x) is x) and $assert/$error to the plain call without the source position, so no debug output or source path reaches a prod payload. The CLI sets the variable for prod-channel builds ("0") and dev builds ("1"). rbxtsc's incremental build doesn't see the environment, so switch it only with a clean out/ (the CLI builds prod from a clean, per-channel cache). $git, $compileTime, $nameof, $file and $package always expand.

A declaration opts in with the JSDoc tag @typetorchMacro <name>, which is how macros.d.ts is written; another package can declare (or re-export) the same macros with the same tags.

Class metadata ​

Decorators made with Modding.createDecorator or Modding.createMetaDecorator are TypeTorch decorators. The transformer removes them from the class and emits, for

ts
/** @metadata typetorch:parameters injectable */
export const Service = Modding.createDecorator<[config?: ModuleConfig]>("Class", (descriptor, [config]) => {
	registered.push({ ctor: descriptor.object, config: config ?? {} });
});

@Service({ loadOrder: 10 })
export class ShopService {
	constructor(private readonly data: DataService, private readonly logger: FrameworkLogger) {}
}

this Luau:

lua
-- inside the class definition
Reflect.defineMetadata(ShopService, "identifier", "server/services@ShopService")
Reflect.defineMetadata(ShopService, "typetorch:parameters", { "server/services@DataService", "@typetorch/framework:logger@FrameworkLogger" })
-- after it
Reflect.decorate(ShopService, "@typetorch/framework:decorators@Service", Service, { { loadOrder = 10 } })

Reflect.decorate runs the decorator's callback with { id, object, constructor, property?, isStatic? } and the arguments. A dependency id resolves to its class through Reflect.idToObj.get(id) (or Reflect.getObjectFromId).

Lazy parameters (0.2.1). A parameter whose type has the type-only property _typetorch_lazy: T (the framework's Lazy<T>) records lazy:<id of T> instead of its own type's id: constructor(private readonly team: Lazy<TeamService>) gives { "lazy:server/services/team@TeamService" }. The framework hands it a handle that resolves T on first use and leaves that edge out of the start order, so two modules can take each other. Without the marker, a type reference's id drops its type arguments (Lazy<A> and Lazy<B> would both be Lazy's id).

Metadata is requested with JSDoc @metadata <keys...> on the class itself, on a decorator's declaration, or on an interface the class implements. Prefix a key with ~ to opt out of it, * asks for everything.

KeyWritten onValue
identifierclassits id; always written for decorated classes (registers Reflect.idToObj)
typetorch:parametersclass (constructor), methodsdependency ids of the parameters
typetorch:parameter_namesclass (constructor), methodsparameter names
typetorch:parameter_guardsclass (constructor), methodsa guard per parameter
typetorch:implementsclassids of the interfaces in its implements clause
typetorch:type / typetorch:guardpropertiesthe property type's id / guard
typetorch:return_type / typetorch:return_guardmethodsthe return type's id / guard
reflectclassemit metadata even without a TypeTorch decorator
injectabledecoratorno effect; documents that the decorator's classes are injected

@metadata macro marks user macros (above). The runtime also writes typetorch:decorators (decorator ids per class or property) and typetorch:decorators.<id> ({ arguments }). @metadata {@link SomeType constraint} on a decorator makes every class (or member) it decorates fail to compile unless it is assignable to SomeType.

Ids ​

Ids are deterministic and need no build-info file:

  • A declaration in the project being compiled: <module>@<Name>, where <module> is the output module path relative to outDir without extension (index becomes init). src/server/services.ts → server/services@ShopService.
  • When the project is a package (rbxtsc --type package, or a scoped package name, the same rule roblox-ts uses), it is prefixed with the package name: @typetorch/framework:net/init@Network.
  • A declaration from another package's .d.ts files: <package>:<module>@<Name>, with <module> relative to the directory of the package's types entry (its outDir).

So a declaration in a package has the same id whether the package compiles itself or a game sees its declaration files, as long as the package's types point into its outDir (the roblox-ts default). Nested declarations include their namespaces (Outer.Inner). Primitives are $p:string, $p:number, ...; literals are $ps:<text> and $pn:<number>.

Runtime kit ​

runtime-kit/ holds the roblox-ts source of the runtime half. @typetorch/framework carries copies of reflect.ts and modding.ts in src/reflection/ and exports them from its root; the kit is the source of truth, so a change here is copied there and the two stay identical. It contains:

  • Reflect: defineMetadata (with the special identifier key), getOwnMetadata, hasOwnMetadata, getMetadata and getMetadatas (walk superclasses), decorate, the idToObj / objToId maps, getObjectFromId, getIdFromObject and the decorators registry.
  • Modding: createDecorator and createMetaDecorator (Class, Method, Property), getDecoratorArguments, and the macro types Generic, GenericMany, Many, Caller, CallerMany, TupleLabels.
  • t, re-exported from @rbxts/t as the fallback for generated guards.

The macro types are recognised by marker properties that only exist in the type system (_typetorch_macro_generic, _typetorch_macro_many, _typetorch_macro_caller, _typetorch_macro_tuple_labels, _typetorch_decorator, _typetorch_parameters). Flamework's Modding types are not recognised.

With the TypeTorch CLI ​

The CLI doesn't configure the transformer; the game's tsconfig.json does (above). It does depend on its output:

  • typetorch migrate --from flamework (tt works as an alias) flags a tsconfig that still lists rbxts-transformer-flamework (replace it with @typetorch/transformer) or lacks @typetorch/transformer, and a package.json without the TypeTorch packages.
  • typetorch build hashes the arguments of every createNetwork(...) and createFlameworkCompat(...) call in the compiled Luau (the guard trees this transformer generates) into the payload's ProtocolHash, warns about calls without generated guards (the transformer missing from the plugins), and notes a game still on createFlameworkCompat.
  • Prod builds compile with TYPETORCH_DEBUG_MACROS=0 (and a generated tsconfig whose first plugin strips rbxts-transform-debug's macros, for projects still on it); this transformer strips its own debug macros from the variable.
  • typetorch build (CLI 0.16+) compiles with goblox, a Go port of roblox-ts, when node_modules/.bin/goblox is installed (bun add -d goblox), or when TYPETORCH_COMPILER / typetorch.json "compiler" asks for it. goblox runs this transformer natively (a Go port, from the same tsconfig plugins entry, TYPETORCH_DEBUG_MACROS included) and ignores every other JS plugin. Otherwise it runs rbxtsc with this package.

Migrating from Flamework ​

FlameworkTypeTorch
rbxts-transformer-flamework plugin@typetorch/transformer plugin
import { Modding, Reflect } from "@flamework/core"import { Modding, Reflect } from "@typetorch/framework"
typeRoots: [..., "node_modules/@flamework"]typeRoots: [..., "node_modules/@typetorch"]
@metadata flamework:implements flamework:parameters injectable@metadata typetorch:parameters injectable (add typetorch:implements if you read it)
"flamework:parameters", "flamework:implements", "flamework:decorators" keys"typetorch:parameters", "typetorch:implements", "typetorch:decorators"
Reflect.idToObj (internal)Reflect.idToObj (public Map) or Reflect.getObjectFromId(id)
@metadata macro, Modding.Generic/Many/Caller/TupleLabelsunchanged names, TypeTorch's Modding
import { Dependency } from "@flamework/core"import { Dependency } from "@typetorch/framework" (from onInit on, not in constructors)
flamework.build, flamework.json, include/flamework/gone; delete them

Dependency<T>() keeps its name and call: it is a user macro of @typetorch/framework now (Modding.Generic<T, "id">, like TypeTorch.module<T>() and the field form Lazy<T>()), not a transformer intrinsic.

Not carried over: Flamework.addPaths / ignite, Flamework's other dependency macros (Flamework.id, Flamework.createGuard, Flamework.implements; write a user macro instead), components and attribute guards, networking intrinsics and key obfuscation, Modding.Hash / Obfuscate / Intrinsic, id obfuscation modes, hashPrefix / salt, build-info files, and the flamework.json config. Ids changed format (flamework.build is gone), so data saved with Flamework ids does not carry over.

typetorch migrate --from flamework (CLI) rewrites a Flamework game's imports, modules, ignite files and networking, and flags the rest; the docs' Coming from Flamework guide has the whole mapping.

Developing ​

sh
bun install
bun run build    # tsc -> out/
bun run test     # builds, then runs test-project/scripts/run.ts

The test fixture compiles a stand-in @typetorch/framework, laid out like the real one (the runtime kit, Service and Controller in decorators.ts, an abstract Module base, createNetwork in net/index.ts with GuardTree in net/types.ts, a decorated class; built as a package with this transformer), and a Model project that uses it. It builds the model with Rojo and checks the emitted Luau: a guard per nested leaf, the id strings, typetorch:parameters (also on derived classes and classes without a constructor), the Reflect import, the t re-export, and that a package declaration has the same id in both compiles, and the debug macros (src/shared/debug-macros.ts): their output with the macros on, and a second compile with TYPETORCH_DEBUG_MACROS=0 that strips them. It also checks the network leaf diagnostics: a compile with a leaf that takes a class fails with an error naming the leaf, and src/shared/leaf-warnings.ts (a generic leaf, an AnimationTrack, a function, a thread and a connection) compiles with one warning each, at the leaf. Then test-project/scripts/runtime.luau runs the model under Lune (pinned in test-project/rokit.toml): two generations in one emulated VM, each with a fresh Reflect registry, resolving DI ids and running the generated guards. Rojo and Lune must be on PATH (rokit install in test-project/).

The package ships only out/, macros.d.ts, README.md and LICENSE (bun pm pack --dry-run lists them). prepublishOnly cleans and rebuilds out/.

Releasing ​

Releases go to npm only from a version tag, through .github/workflows/publish.yml (the repo's only workflow; there is no CI on pushes or pull requests, so run bun run test locally):

sh
npm version <x.y.z> -m "release %s" && git push origin main --follow-tags

The workflow checks that the tag matches package.json and that the version isn't on npm yet, then runs npm publish --provenance (which runs prepublishOnly) and creates a GitHub release with generated notes. It authenticates with npm trusted publishing (environment npm), falling back to an NPM_TOKEN secret until that is set up. A prerelease version (x.y.z-next.0) goes to the next dist-tag, anything else to latest.

Credits and license ​

Forked from rbxts-transformer-flamework by Fireboltofdeath and the Flamework contributors; the runtime kit is adapted from @flamework/core. Both are MIT licensed. This package is MIT too; see LICENSE for both copyright notices.

Released under the MIT License.