Appearance
Synced from the transformer README.
@typetorch/transformer
The roblox-ts transformer behind TypeTorch. It does three things at compile time:
- User macros. A function tagged
@metadata macrogets its omitted trailing parameters filled in from types: runtimetguards, stable type ids, call-site info. Nested and mapped types work, socreateNetwork<C2S, S2C>()gets one guard per method of a nested interface. - Class metadata for dependency injection. Classes decorated with a TypeTorch decorator get an
identifier, their constructor's dependency ids (typetorch:parameters) and aReflect.decoratecall. - Debug and build-info macros.
$print,$warn,$error,$assert,$dbg,$nameof,$file,$package,$gitand$compileTimefrom@typetorch/transformer/macros: whatrbxts-transform-debugdid, 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": "..." }.
| Option | Default | Meaning |
|---|---|---|
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. |
guardModule | automatic | Module 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. |
debugMacros | true | Whether $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. |
semanticDiagnostics | false | Type-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.guardGenerationDedupLimit | off | Types 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")| Type | Filled 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:
| Type | Guard |
|---|---|
string, number, boolean, undefined/void, any, unknown | t.string, t.number, t.boolean, t.none, t.any, t.union(t.any, t.none) |
literals, literal unions, TS enums | t.literal(...) / t.literalList({...}) |
T | undefined, optional properties | t.optional(...) |
| unions, intersections | t.union / t.unionList, t.intersection / t.intersectionList |
tuples (Parameters<F>) | t.strictArray(...) |
T[], Map<K, V>, Set<T>, index signatures | t.array, t.map, t.set, t.map(key, value) |
| objects and interfaces | t.interface({...}) |
Roblox datatypes (Vector3, CFrame, Color3, ...), Enum.X, buffer | t.Vector3, ..., t.enum(Enum.X), t.typeof("buffer") |
Instance types | t.instanceIsA("BasePart"), plus t.children for declared children |
| functions | t.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:
| Leaf | Diagnostic |
|---|---|
| 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 AnimationTrack | warning: 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).
| Macro | Compiles 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, $file | the file's path relative to the project (forward slashes), the line, or both as an object |
$package.version, $package | a 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.
| Key | Written on | Value |
|---|---|---|
identifier | class | its id; always written for decorated classes (registers Reflect.idToObj) |
typetorch:parameters | class (constructor), methods | dependency ids of the parameters |
typetorch:parameter_names | class (constructor), methods | parameter names |
typetorch:parameter_guards | class (constructor), methods | a guard per parameter |
typetorch:implements | class | ids of the interfaces in its implements clause |
typetorch:type / typetorch:guard | properties | the property type's id / guard |
typetorch:return_type / typetorch:return_guard | methods | the return type's id / guard |
reflect | class | emit metadata even without a TypeTorch decorator |
injectable | decorator | no 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 tooutDirwithout extension (indexbecomesinit).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.tsfiles:<package>:<module>@<Name>, with<module>relative to the directory of the package'stypesentry (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 specialidentifierkey),getOwnMetadata,hasOwnMetadata,getMetadataandgetMetadatas(walk superclasses),decorate, theidToObj/objToIdmaps,getObjectFromId,getIdFromObjectand thedecoratorsregistry.Modding:createDecoratorandcreateMetaDecorator(Class, Method, Property),getDecoratorArguments, and the macro typesGeneric,GenericMany,Many,Caller,CallerMany,TupleLabels.t, re-exported from@rbxts/tas 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(ttworks as an alias) flags a tsconfig that still listsrbxts-transformer-flamework(replace it with@typetorch/transformer) or lacks@typetorch/transformer, and apackage.jsonwithout the TypeTorch packages.typetorch buildhashes the arguments of everycreateNetwork(...)andcreateFlameworkCompat(...)call in the compiled Luau (the guard trees this transformer generates) into the payload'sProtocolHash, warns about calls without generated guards (the transformer missing from the plugins), and notes a game still oncreateFlameworkCompat.- Prod builds compile with
TYPETORCH_DEBUG_MACROS=0(and a generated tsconfig whose first plugin stripsrbxts-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, whennode_modules/.bin/gobloxis installed (bun add -d goblox), or whenTYPETORCH_COMPILER/ typetorch.json"compiler"asks for it. goblox runs this transformer natively (a Go port, from the same tsconfigpluginsentry,TYPETORCH_DEBUG_MACROSincluded) and ignores every other JS plugin. Otherwise it runsrbxtscwith this package.
Migrating from Flamework
| Flamework | TypeTorch |
|---|---|
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/TupleLabels | unchanged 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.tsThe 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-tagsThe 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.