Nest.js를 비롯한 많은 typescript di 패키지들은 reflect-metadata
를 기반으로 DI를 제공한다고 알려져있습니다. 그럼 typescript 5.0부터 추가된 decorator로 비슷한 기능을 만들 수 있을까요?
Typescript 5.0 부터
experimentalDecorators
설정 없이 decorator를 사용할 수 있도록 ts39의 proposal-decorators 의 decorator 구현이 추가되었습니다.
reflect-metadata 에 대한 자세한 설명은 김정환님의 블로그를 참고하시길 바랍니다.
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로 구현할 수 있을까요?
tsconfig에서 experimentalDecorators
와 함께 emitDecoratorMetadata
를 활성화 하면 기본적으로 아래 메타데이터들을 추가합니다.
일반적으로 Typescript는 컴파일 타임(design-time)에 타입 정보가 사용되고 런타임에는 타입 정보가 사라지지만 emitDecoratorMetadata
와 함께 reflect-metadata
를 사용하면 런타임에도 이 타입 정보를 사용할 수 있습니다.
※
design:type
과design: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)
를 통해 런타임에 가져올 수 있습니다.
// 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;
}
//...
// 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;
}
// 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를 사용하려면 experimentalDecorators
와 emitDecoratorMetadata
옵션을 비활성화 해야하고 비활성화를 하게 되면 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를 할 수 있을거라 기대해봅니다.