Skip to Content
Course content

200: Metadata Reflection with Decorators

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When most developers first dive into decorators, they assume that TypeScript's type system is still alive and kicking at runtime. I see this constantly: someone writes a decorator and expects to be able to ask, "Hey, is this property a string or a number?" and expects a straight answer. They think the decorator has a magical window into the TypeScript compiler's soul.

The "Magic Type" Myth

Let's look at why that's a trap. You might try to write something like this, thinking you can create a generic validation decorator:

function ValidateType(target: any, key: string) {
  // You'd expect to find the type here, right?
  const type = Reflect.getMetadata("design:type", target, key);
  console.log(`Property ${key} is of type: ${type}`);
}

class User {
  @ValidateType
  username: string;
}

If you run this in a standard TypeScript environment, type will be undefined. Why? Because TypeScript is a compiled language. Once the code hits the browser or Node.js, all those beautiful type annotations—the : string and : number—are stripped away. They are literally deleted. Your JavaScript runtime has no idea that username was ever intended to be a string.

Bridging the Gap with Reflect-Metadata

To actually get metadata reflection, we have to explicitly tell TypeScript to stop deleting that information and instead "bake" it into the JavaScript output. You need two things: the reflect-metadata polyfill and a specific flag in your tsconfig.json.

  • First, run npm install reflect-metadata.
  • Second, set "emitDecoratorMetadata": true and "experimentalDecorators": true in your tsconfig.json.

Now, TypeScript does something interesting. When it sees a decorator on a property, it automatically injects metadata using the Reflect API. Specifically, it creates keys like design:type, design:paramtypes, and design:returntype. I've found that relying on these "design" keys is the secret sauce for building things like Dependency Injection containers or automated API validators.

Defining Your Own Custom Metadata

While the design:type is useful, you usually want to store your own custom business logic. This is where Reflect.defineMetadata comes into play. Instead of just reacting to the type, you can attach your own "tags" to classes or methods.

Let's build a real-world example: a Required decorator that marks specific fields for a validation engine to check later.

import "reflect-metadata";

const REQUIRED_METADATA_KEY = Symbol("required");

function Required(target: any, propertyKey: string) {
  // We aren't changing the property; we're just tagging it.
  Reflect.defineMetadata(REQUIRED_METADATA_KEY, true, target, propertyKey);
}

class UserProfile {
  @Required
  email: string;

  username: string; // Not required
}

function validate(obj: any) {
  for (let key in obj) {
    const isRequired = Reflect.getMetadata(REQUIRED_METADATA_KEY, obj, key);
    if (isRequired && !obj[key]) {
      throw new Error(`Field ${key} is required but missing!`);
    }
  }
}

const user = new UserProfile();
user.username = "dev_pro";
// user.email is missing!
validate(user); // Throws: Field email is required but missing!

Notice what happened here. The @Required decorator didn't actually "do" anything to the UserProfile class at the moment it was defined. It simply left a note in the metadata store. The validate function then came along later, read those notes, and enforced the logic. This separation of declaration (the decorator) and execution (the validator) is exactly how frameworks like NestJS or Angular handle their heavy lifting.




📋 Practical Task

Build a Metadata-Driven Role Access Guard

Your task is to create a system that restricts access to class methods based on user roles. You will need to use reflect-metadata to store the required role for each method and a guard function to check the user's role against that metadata.

Requirements:

  • Create a @Role(roleName: string) decorator that attaches the role name to the method using Reflect.defineMetadata.
  • Create a class AdminPanel with two methods: deleteUser() (requires the role 'admin') and viewDashboard() (requires the role 'user').
  • Implement a function executeSecurely(instance: any, methodName: string, userRole: string). This function should:
    1. Retrieve the required role for the method using Reflect.getMetadata.
    2. If the method has a required role and the userRole doesn't match, throw an error: "Access Denied: Insufficient Permissions".
    3. If the roles match (or no role is required), execute the method and return its result.

Test Case: Try calling executeSecurely(panel, 'deleteUser', 'user') to ensure it blocks the call, and executeSecurely(panel, 'deleteUser', 'admin') to ensure it allows it.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.