DEV Community

Remo H. Jansen
Remo H. Jansen

Posted on

RFLCT: Bringing Runtime Type Metadata to TypeScript 7

If you've built large-scale applications in TypeScript, chances are you've used a Dependency Injection (DI) container. As the creator of InversifyJS, I've spent years thinking deeply about inversion of control, decoupling, and how to make enterprise patterns feel natural in TypeScript.

But for all those years, there has been a glaring elephant in the room: our heavy reliance on experimentalDecorators and emitDecoratorMetadata.

These compiler flags have served us well, but they are exactly that—experimental. They tie us to legacy decorator implementations, require specific compiler configurations, and often feel like a magic black box that doesn't perfectly align with modern build pipelines. I've spent a lot of time recently thinking about how we could finally drop these flags entirely while keeping the developer experience pristine.

With the release of TypeScript 7, I'm thrilled to introduce the solution: 🪞 RFLCT.

What is RFLCT?

RFLCT is an ahead-of-time (AOT) reflect metadata injector for TypeScript 7. It injects design:symbols and design:arguments directly at build time.

Zero decorators. Zero emitDecoratorMetadata.

It integrates seamlessly with virtually any build tool (Vite, Rollup, webpack, esbuild) via unplugin, or you can use the built-in CLI using the TypeScript 7 API for standalone tsgo projects.

Let's look at how it actually feels to write code with RFLCT.

The Magic: Before and After

With RFLCT, you annotate the types you want to expose to your runtime metadata using a special Reflect<T> wrapper type.

What you write:

import { Reflect, resolve } from "rflct";

interface Shape { sides: number; }

class Polygon {
  constructor(
    public shape: Reflect<Shape>,
    public label: Reflect<string, { optional: true }>
  ) {}
}

// resolve<T>() → the runtime identity of T (Symbol for interfaces, class for classes)
container.bind(resolve<Shape>()).to(Polygon);
Enter fullscreen mode Exit fullscreen mode

What RFLCT compiles it to:

Notice how the interfaces are safely converted into global Symbols, and metadata is explicitly registered without a single decorator in sight.

import "reflect-metadata";

const __RFLCT_Shape = Symbol.for("@acme/shapes@1|src/geo.ts|Shape");

class Polygon {
  constructor(shape, label) {}
}

Reflect.defineMetadata("design:arguments", [
  { type: __RFLCT_Shape, metadata: {} },
  { type: String, metadata: { optional: true } }
], Polygon, undefined);

container.bind(__RFLCT_Shape).to(Polygon);

Reflect.defineMetadata("design:symbols", Object.assign(
  Reflect.getMetadata("design:symbols", Reflect) ?? {}, {
    "@acme/shapes@1|src/geo.ts|Shape": __RFLCT_Shape,
    "@acme/shapes@1|src/geo.ts|Polygon": Polygon,
  }
), Reflect);
Enter fullscreen mode Exit fullscreen mode

Broader Than Just InversifyJS

While my primary motivation for building RFLCT was to pave the way for the next generation of InversifyJS, this underlying primitive—a reliable, decorator-free way to emit runtime type metadata—unlocks so much more.

Because RFLCT standardizes how types are mapped to memory at build time, it has massive potential across the ecosystem:

  • Custom DI Engines: Build your own lightweight inversion of control containers without metadata boilerplate.
  • Object Mapping & Hydration: Easily map database or API JSON results directly back into instantiated classes.
  • RPC Frameworks: Guarantee type-safe network boundaries by validating incoming arguments against compile-time metadata.
  • Runtime Validation: Perform deep runtime validation by reading exactly what types a constructor or method expects.

How It Works: The Three Transformations

Under the hood, RFLCT performs three core transformations during your build step:

1. design:symbols — The Global Type Registry

Every class, interface, and type alias in a file is registered in a process-wide Map on the global Reflect object.

  • Interfaces & Types become universally unique Symbols (e.g., Symbol.for(qualifiedName)).
  • Classes map directly to their constructor.

2. design:arguments — Parameter Type Metadata

Any parameter annotated with Reflect<T> (or Reflect<T, Metadata>) tells the compiler to produce a Reflect.defineMetadata("design:arguments", [...], target, key) call:

  • Constructors: target = ClassName, key = undefined
  • Methods: target = ClassName.prototype, key = "methodName"

3. resolve<T>() — Compile-Time Type Resolution

Writing resolve<T>() acts as a macro. It is replaced at compile time with the actual runtime identity of T:

  • resolve<Shape>() compiles to Symbol.for("...Shape")
  • resolve<Triangle>(Triangle) compiles simply to Triangle

Smart Symbol Qualification

A massive headache with metadata in the past has been duplicate dependencies causing symbol collisions. Generated symbols use Symbol.for(qualifiedName) structured as packageName@majorVersion|packageRelativePath|TypeName. This prevents collisions, allows minor/patch versions to share symbols, and ensures that shared library types resolve to the exact same symbol reference in memory.


Getting Started (Alpha Preview)

⚠️ **Note:* RFLCT is currently in its early 0.0.1-alpha.0 stage and the npm module has not yet been published to the public registry.*

For now, to try it out, you will need to download the source directly from GitHub and build it locally.

1. Build from Source

git clone https://github.com/your-username/rflct.git
cd rflct
npm install
npm run build
npm link
Enter fullscreen mode Exit fullscreen mode

2. Project Setup

Link the local package to your project:

npm link rflct
npm install reflect-metadata
Enter fullscreen mode Exit fullscreen mode

You'll need to import reflect-metadata once at your application's entry point to polyfill the global Reflect.defineMetadata APIs:

// entry.ts
import "reflect-metadata";
Enter fullscreen mode Exit fullscreen mode

3. Configure Your Build Tool

Because RFLCT is built on top of unplugin, it drops straight into your existing pipeline.

Vite:

// vite.config.js
import { vitePlugin } from "rflct/vite";

export default {
  plugins: [vitePlugin()],
};
Enter fullscreen mode Exit fullscreen mode

(Plugins for Rollup, esbuild, and webpack are also available out of the box!)

If you are running a standalone tsgo project without a bundler, the CLI leverages the TS 7 API:

npx rflct -p tsconfig.json -o dist --check
Enter fullscreen mode Exit fullscreen mode

Looking Forward & Next Steps

Dependency Injection in TypeScript is finally growing up. By moving metadata generation to a clean, predictable build step, we can leave experimental decorators in the past and write DI code that feels native to modern TypeScript.

So, what's next?
The immediate next step for me is to start experimenting with integrating RFLCT directly into InversifyJS. I'll be testing how seamlessly we can migrate existing IoC containers to this new AOT metadata approach.

I can't wait to see what you build with it! Clone the repo, try it in your projects, and let me know what you think.

Top comments (0)