Typescript 5.0 Decorator는 reflect-metadata로 DI를 할 수 있을까?

June·2024년 1월 21일
0
post-thumbnail

Nest.js를 비롯한 많은 typescript di 패키지들은 reflect-metadata 를 기반으로 DI를 제공한다고 알려져있습니다. 그럼 typescript 5.0부터 추가된 decorator로 비슷한 기능을 만들 수 있을까요?

Typescript 5.0 부터 experimentalDecorators 설정 없이 decorator를 사용할 수 있도록 ts39의 proposal-decorators 의 decorator 구현이 추가되었습니다.

reflect-metadata

reflect-metadata 에 대한 자세한 설명은 김정환님의 블로그를 참고하시길 바랍니다.

Typescript 5.x의 Decorator context 정의

ClassDecoratorContext 의 타입 정의는 다음과 같습니다.

type DecoratorMetadataObject = Record<PropertyKey, unknown> & object;

type DecoratorMetadata = typeof globalThis extends { Symbol: { readonly metadata: symbol; }; } ? DecoratorMetadataObject : DecoratorMetadataObject | undefined;

/**
 * Context provided to a class decorator.
 * @template Class The type of the decorated class associated with this context.
 */
interface ClassDecoratorContext<
    Class extends abstract new (...args: any) => any = abstract new (...args: any) => any,
> {
    /** The kind of element that was decorated. */
    readonly kind: "class";

    /** The name of the decorated class. */
    readonly name: string | undefined;

    /**
     * Adds a callback to be invoked after the class definition has been finalized.
     *
     * @example
     * ```ts
     * function customElement(name: string): ClassDecoratorFunction {
     *   return (target, context) => {
     *     context.addInitializer(function () {
     *       customElements.define(name, this);
     *     });
     *   }
     * }
     *
     * @customElement("my-element")
     * class MyElement {}
     * ```
     */
    addInitializer(initializer: (this: Class) => void): void;

    readonly metadata: DecoratorMetadata;
}

ClassMethodDecorator의 타입 정의는 다음과 같습니다

/**
 * Context provided to a class method decorator.
 * @template This The type on which the class element will be defined. For a static class element, this will be
 * the type of the constructor. For a non-static class element, this will be the type of the instance.
 * @template Value The type of the decorated class method.
 */
interface ClassMethodDecoratorContext<
    This = unknown,
    Value extends (this: This, ...args: any) => any = (this: This, ...args: any) => any,
> {
    /** The kind of class element that was decorated. */
    readonly kind: "method";

    /** The name of the decorated class element. */
    readonly name: string | symbol;

    /** A value indicating whether the class element is a static (`true`) or instance (`false`) element. */
    readonly static: boolean;

    /** A value indicating whether the class element has a private name. */
    readonly private: boolean;

    /** An object that can be used to access the current value of the class element at runtime. */
    readonly access: {
        /**
         * Determines whether an object has a property with the same name as the decorated element.
         */
        has(object: This): boolean;
        /**
         * Gets the current value of the method from the provided object.
         *
         * @example
         * let fn = context.access.get(instance);
         */
        get(object: This): Value;
    };

    /**
     * Adds a callback to be invoked either before static initializers are run (when
     * decorating a `static` element), or before instance initializers are run (when
     * decorating a non-`static` element).
     *
     * @example
     * ```ts
     * const bound: ClassMethodDecoratorFunction = (value, context) {
     *   if (context.private) throw new TypeError("Not supported on private methods.");
     *   context.addInitializer(function () {
     *     this[context.name] = this[context.name].bind(this);
     *   });
     * }
     *
     * class C {
     *   message = "Hello";
     *
     *   @bound
     *   m() {
     *     console.log(this.message);
     *   }
     * }
     * ```
     */
    addInitializer(initializer: (this: This) => void): void;

    readonly metadata: DecoratorMetadata;
}

이외에 다른 DecoratorContext 타입 정의 에서 볼 수 있듯이 addInitializer의 callback에 사용자가 데코레이터가 적용된 객체의 this 와 함께 초기화 시점에 추가 초기화 로직을 할당할 수 있습니다.

이제 addInitializer 의 callback에서 메타데이터를 정의해보겠습니다.

injectable.decorator.ts

import { INJECTABLE_METADATA_KEY } from 'src/constants';

export function Injectable(metadata?: any) {
  return (target: any, context: ClassDecoratorContext) => {
    console.log('target', target);
    console.log('context : ', context);
    context.addInitializer(function () {
      // 데코레이터 팩토리의 파라미터로 전달받은 데이터를 메타데이터로 정의
      Reflect.defineMetadata(INJECTABLE_METADATA_KEY, metadata, this);
    });
  };
}

test.class.ts

@Injectable('메타데이터')
export class Test {
  public print(text: string): void {
    console.log(text);
  }
}

index.ts

import 'reflect-metadata';
import { Test } from 'src/classes/test.class';
import { INJECTABLE_METADATA_KEY } from 'src/constants';

const meta = Reflect.getMetadata(INJECTABLE_METADATA_KEY, Test);

console.log('meta : ', meta);

실행결과

target [class Test]
context :  {
  kind: 'class',
  name: 'Test',
  metadata: undefined,
  addInitializer: [Function (anonymous)]
}
meta :  메타데이터

실행결과를 보면 Reflect 를 이용해 추가한 메타데이터가 잘 적용되어 있습니다. (Reflect에 데코레이터 팩토리의 파라미터로 전달한 "메타데이터" 라는 문자열을 INJECTABLE_METADATA_KEY를 키로 저장하고 조회할 수 있습니다.)

그럼 typedi, tsyringe 또는 Nest.js의 처럼 reflect-metadata 기반의 di 를 위한 decorator를 typescript 5.0의 decorator로 구현할 수 있을까요?

emitDecoratorMetadata 와 design-time type 메타데이터

tsconfig에서 experimentalDecorators 와 함께 emitDecoratorMetadata를 활성화 하면 기본적으로 아래 메타데이터들을 추가합니다.

  • design:type: 데코레이터가 적용된 객체의 타입 정보
  • design:paramtypes : 데코레이터가 적용된 곳의 파라미터의 타입 정보
  • design:returntype: 데코레이터가 적용된 함수가 반환해야하는 타입 정보

일반적으로 Typescript는 컴파일 타임(design-time)에 타입 정보가 사용되고 런타임에는 타입 정보가 사라지지만 emitDecoratorMetadata와 함께 reflect-metadata를 사용하면 런타임에도 이 타입 정보를 사용할 수 있습니다.

design:typedesign:returntype 은 클래스 내부의 프로퍼티, 메서드, 메서드 매개변수의 데코레이터에서 접근가능합니다.

import 'reflect-metadata';

function DesignType() {
  return (target: any) => {
    const paramtypes = Reflect.getMetadata('design:paramtypes', target);
    console.log(`${target.name} constructor paramtypes: ${paramtypes.map((t: any) => t.name).join(', ')}`);
  };
}

class A {
  constructor() {
    console.log('A');
  }
}

class B {
  constructor(private readonly a: A) {
    console.log('B');
  }
}

@DesignType()
class C {
  constructor(
    private readonly a: A,
    private readonly b: B,
  ) {
    console.log('C');
  }
}

실행결과

C constructor paramtypes: A, B

위의 C 클래스는 js로 컴파일 됐을 때 아래와 같습니다

var C = /** @class */ (function () {
  function C(a, b) {
    this.a = a;
    this.b = b;
    console.log('C');
  }
  C = tslib_1.__decorate([DesignType(), tslib_1.__metadata('design:paramtypes', [A, B])], C);
  return C;
})();

데코레이터가 적용된 C 클래스의 생성자 매개변수의 타입(A, B)을 Reflect.getMetadata('design:paramtypes', target) 를 통해 런타임에 가져올 수 있습니다.

Nest.js 예시

// packages/common/constants.ts 의 일부
export const PARAMTYPES_METADATA = 'design:paramtypes';
// packages/core/injector/injector.ts 의 일부
public getClassDependencies<T>(
    wrapper: InstanceWrapper<T>,
  ): [InjectorDependency[], number[]] {
    const ctorRef = wrapper.metatype as Type<any>;
    return [
      this.reflectConstructorParams(ctorRef),
      this.reflectOptionalParams(ctorRef),
    ];
  }

//...

public reflectConstructorParams<T>(type: Type<T>): any[] {
    const paramtypes = [
      ...(Reflect.getMetadata(PARAMTYPES_METADATA, type) || []),
    ];
    const selfParams = this.reflectSelfParams<T>(type);

    selfParams.forEach(({ index, param }) => (paramtypes[index] = param));
    return paramtypes;
  }

//...

tsyringe 예시

// src/reflection-helpers.ts 의 일부 
export function getParamInfo(target: constructor<any>): ParamInfo[] {
  const params: any[] = Reflect.getMetadata("design:paramtypes", target) || [];
  const injectionTokens: Dictionary<InjectionToken<any>> =
    Reflect.getOwnMetadata(INJECTION_TOKEN_METADATA_KEY, target) || {};
  Object.keys(injectionTokens).forEach(key => {
    params[+key] = injectionTokens[key];
  });

  return params;
}

typedi 예시

// src/utils/resolve-to-type-wrapper.util.ts 일부
xport function resolveToTypeWrapper(
  typeOrIdentifier: ((type?: never) => Constructable<unknown>) | ServiceIdentifier<unknown> | undefined,
  target: Object,
  propertyName: string | Symbol,
  index?: number
): { eagerType: ServiceIdentifier | null; lazyType: (type?: never) => ServiceIdentifier } {
  // ... (생략)
  if (!typeOrIdentifier && propertyName) {
    const identifier = (Reflect as any).getMetadata('design:type', target, propertyName);

    typeWrapper = { eagerType: identifier, lazyType: () => identifier };
  }

  /** If no explicit type is set and handler registered for a constructor parameter, we need to get the parameter types. */
  if (!typeOrIdentifier && typeof index == 'number' && Number.isInteger(index)) {
    const paramTypes: ServiceIdentifier[] = (Reflect as any).getMetadata('design:paramtypes', target, propertyName);
    /** It's not guaranteed, that we find any types for the constructor. */
    const identifier = paramTypes?.[index];

    typeWrapper = { eagerType: identifier, lazyType: () => identifier };
  }

  return typeWrapper;
}

위의 DI를 사용하는 패키지들의 코드에서 볼 수 있는 것처럼 typescript 기반의 DI 패키지들은 데코레이터가 적용된 클래스의 의존성에 대한 정보를 얻기 위해 design-time type 메타데이터를 이용합니다.

※ 모든 패키지가 design-time type 메타데이터를 이용하지는 않을 수도 있습니다.

하지만 Typescript 5.0에 구현된 decorator를 사용하려면 experimentalDecoratorsemitDecoratorMetadata 옵션을 비활성화 해야하고 비활성화를 하게 되면 design-time type 메타데이터는 사용할 수 없게 되어 런타임에 의존성 주입을 위한 메서드나 생성자의 파라미터의 타입 정보를 얻을 수 없게 됩니다.

결론

typescript 5.0에 구현된 decorator와 함께 reflect-metadata를 사용할 수는 있지만 결론적으로 emitDecoratorMetadata 옵션을 활성화 해야만 사용할 수 있는 design-time type 메타데이터를 얻을 수 없어 런타임에 의존성 주입을 위한 타입 정보를 얻을 수 없습니다.

결론적으로 모든 의존성 정보를 custom token 을 이용해 관리한다면 Decorator 기반의 DI 기능을 구현할 수는 있겠지만 추가적인 token 설정 없이 typescript 5.0의 decorator 구현으로는 아직 현존하는 DI 패키지들과 유사한 형태를 구현하기는 힘들어보입니다.

proposal-decorators 처럼 Decorator Metadata 도 정식으로 구현된다면 reflect-metadata 도움 없이 메타프로그래밍과 DI를 할 수 있을거라 기대해봅니다.

0개의 댓글

관련 채용 정보