Published on

TS 5.5+ Decorators 적용 시 타입오류 6가지

Authors

서드파티(예: NestJS, TypeORM)나 사내 프레임워크에서 데코레이터를 쓰다 보면, TS 5.5+로 올리면서 갑자기 타입 에러가 폭발하는 경우가 많습니다. 이유는 단순히 “TS가 더 엄격해졌다”가 아니라, 데코레이터 사양 자체가 바뀌었고(TypeScript의 legacy decorators ↔ 표준 decorators) 그에 따라 데코레이터 함수 시그니처, 컨텍스트 타입, 초기화 흐름이 달라졌기 때문입니다.

이 글은 TS 5.5+ 환경에서 표준 Decorators(ECMAScript decorators) 를 적용할 때 실제로 자주 발생하는 타입 오류 6가지를 "에러 메시지 패턴 → 원인 → 해결" 순서로 정리합니다.

> 참고: 런타임/빌드 환경에서 ESM과 TS 설정이 엮여 데코레이터 적용이 더 꼬일 때는 Node.js ESM+TS에서 ERR_MODULE_NOT_FOUND 해결법 도 함께 점검하는 게 좋습니다.

0) 전제: TS 5.5+에서 데코레이터 모드 구분

TS에는 크게 두 계열이 있습니다.

  • Legacy decorators: experimentalDecorators, emitDecoratorMetadata 조합으로 많이 사용(특히 NestJS/TypeORM 구버전)
  • 표준 decorators(새 데코레이터): TS 5.x에서 지원이 성숙해진 ECMAScript decorators

둘은 함수 시그니처가 다릅니다.

  • Legacy: (target, propertyKey, descriptor) 스타일
  • 표준: (value, context) 스타일

표준 데코레이터를 쓸 때는 보통 tsconfig에서 legacy 옵션을 끄거나(또는 최소한 혼용을 피하고) 새 시그니처로 작성해야 합니다.

// tsconfig.json 예시(표준 데코레이터 중심)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "strict": true
  }
}

> 프레임워크가 legacy를 강제한다면(예: 특정 버전 NestJS), 표준 데코레이터로 갈아타기 전에 프레임워크/플러그인 호환성을 먼저 확인하세요.

1) 오류: Legacy 시그니처로 작성해 인자 타입이 안 맞음

증상(대표 에러 패턴)

  • Expected 2 arguments, but got 3.
  • Unable to resolve signature of method decorator when called as an expression.

원인

TS 5.5+에서 표준 데코레이터를 사용 중인데, 데코레이터를 legacy 시그니처로 작성한 경우입니다.

// ❌ legacy 스타일(표준 데코레이터 환경에서 깨짐)
function Log(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const original = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(propertyKey, args);
    return original.apply(this, args);
  };
}

class A {
  @Log
  hello(name: string) {
    return `hi ${name}`;
  }
}

해결

표준 데코레이터 시그니처 (value, context)로 바꿉니다.

// ✅ 표준 메서드 데코레이터
function Log<This, Args extends any[], R>(
  value: (this: This, ...args: Args) => R,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => R>
) {
  const name = String(context.name);

  return function (this: This, ...args: Args): R {
    console.log(name, args);
    return value.apply(this, args);
  };
}

class A {
  @Log
  hello(name: string) {
    return `hi ${name}`;
  }
}

핵심은 descriptor를 건드리는 방식이 아니라, 래핑된 함수를 반환하는 방식으로 바뀐다는 점입니다.

2) 오류: 데코레이터가 반환한 타입이 원본과 호환되지 않음

증상

  • Type '(...) => ...' is not assignable to type ...
  • Decorator function return type is not assignable...

원인

표준 데코레이터에서 메서드를 래핑해 반환할 때, 반환 함수의 시그니처(특히 this, 인자, 반환 타입)가 원본과 조금이라도 어긋나면 타입 오류가 납니다.

// ❌ this/인자/반환 타입을 느슨하게 처리해 에러 유발 가능
function Wrap(value: Function, context: ClassMethodDecoratorContext) {
  return function (...args: any[]) {
    return value(...args);
  };
}

해결

제네릭으로 원본 시그니처를 보존합니다.

type AnyMethod = (this: any, ...args: any[]) => any;

function Wrap<M extends AnyMethod>(
  value: M,
  context: ClassMethodDecoratorContext<any, M>
): M {
  const wrapped = function (this: ThisParameterType<M>, ...args: Parameters<M>): ReturnType<M> {
    return value.apply(this, args);
  };
  return wrapped as M;
}

이 패턴을 쓰면 “래핑했더니 타입이 깨진다” 류의 오류 대부분이 정리됩니다.

3) 오류: context.name / context.kind 접근 시 타입 좁히기 실패

증상

  • Property 'name' does not exist on type ...
  • Object is possibly 'undefined'.

원인

표준 데코레이터의 context데코레이터 종류별로 서로 다른 컨텍스트 타입을 가집니다. 예를 들어 필드/메서드/접근자/클래스 데코레이터는 context.kind가 다르고, 가능한 속성도 다릅니다.

또한 context.namestring | symbol일 수 있고, 어떤 경우에는 private name 처리로 인해 추가 고려가 필요합니다.

해결

컨텍스트 타입을 구체화하고, kind로 분기하여 좁힙니다.

function Debug(
  value: unknown,
  context:
    | ClassMethodDecoratorContext
    | ClassFieldDecoratorContext
    | ClassDecoratorContext
) {
  if (context.kind === "method") {
    console.log("method:", String(context.name));
  } else if (context.kind === "field") {
    console.log("field:", String(context.name));
  } else if (context.kind === "class") {
    console.log("class:", context.name);
  }
}

컨텍스트 타입을 대충 any로 덮으면 당장은 편하지만, TS 5.5+에서는 데코레이터가 늘어날수록 유지보수가 급격히 나빠집니다.

4) 오류: 필드 데코레이터에서 value가 없는데 있다고 가정함

증상

  • Object is possibly 'undefined'.
  • Property 'apply' does not exist on type ...

원인

표준 데코레이터에서 필드(field) 데코레이터는 메서드처럼 “함수 value”가 들어오지 않습니다. 필드는 보통 초기화 흐름을 다루며, addInitializer를 통해 인스턴스 생성 시점에 로직을 붙입니다.

// ❌ 필드에 메서드처럼 value를 기대하는 실수
function FieldLikeMethod(value: any, context: ClassFieldDecoratorContext) {
  return function (...args: any[]) {
    return value.apply(this, args);
  };
}

class A {
  @FieldLikeMethod
  x = 1;
}

해결

필드 데코레이터에서는 addInitializer 또는 초기값 변환을 사용합니다.

function DefaultNumber(defaultValue: number) {
  return function (
    initialValue: unknown,
    context: ClassFieldDecoratorContext<any, number>
  ) {
    context.addInitializer(function () {
      const name = String(context.name);
      const v = (this as any)[name];
      if (typeof v !== "number") (this as any)[name] = defaultValue;
    });

    // 초기값을 바꾸고 싶다면 반환
    return typeof initialValue === "number" ? initialValue : defaultValue;
  };
}

class A {
  @DefaultNumber(10)
  x: number = NaN;
}

필드 데코레이터는 “함수 래핑”이 아니라 “초기화/인스턴스 구성”에 가깝다고 이해하면 타입 실수가 줄어듭니다.

5) 오류: addInitializer에서 this 타입이 any/unknown으로 붕 뜸

증상

  • Object is of type 'unknown'.
  • Property '...' does not exist on type ...

원인

addInitializer(function () { ... })의 콜백 내부 this는, 컨텍스트의 제네릭을 제대로 주지 않으면 any 또는 unknown으로 취급되기 쉽습니다. 특히 noImplicitThis/strict에서 자주 터집니다.

해결

컨텍스트에 클래스 인스턴스 타입을 명시하거나, this 파라미터를 명시합니다.

class User {
  name!: string;
}

function Required() {
  return function (
    _initialValue: unknown,
    context: ClassFieldDecoratorContext<User, string>
  ) {
    context.addInitializer(function (this: User) {
      const key = String(context.name);
      if (!(key in this)) {
        throw new Error(`Missing field: ${key}`);
      }
    });
  };
}

class A extends User {
  @Required()
  name = "";
}

요점은 context: ClassFieldDecoratorContext<User, string>처럼 This(인스턴스) 타입을 컨텍스트에 박아주는 것입니다.

6) 오류: 데코레이터 팩토리(인자 받는 데코레이터)에서 반환 타입 추론 실패

증상

  • Unable to resolve signature of decorator when called as an expression.
  • No overload matches this call.

원인

@Deco()처럼 호출하는 데코레이터 팩토리는 “데코레이터 함수를 반환”해야 합니다. 그런데 반환 함수 타입을 넓게(Function) 쓰거나, 메서드/필드/접근자 등 여러 kind를 한 함수로 처리하려다 보면 TS가 어떤 데코레이터인지 확정하지 못합니다.

// ❌ 반환 타입이 너무 넓어 컨텍스트 매칭 실패 가능
function Tag(name: string) {
  return function (value: any, context: any) {
    (context as any).tag = name;
  };
}

해결

목표(kind)를 명확히 하고, 반환 타입을 구체화합니다.

function Memoize() {
  return function <This, Args extends any[], R>(
    value: (this: This, ...args: Args) => R,
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => R>
  ) {
    if (context.kind !== "method") throw new Error("@Memoize is for methods only");

    const cache = new Map<string, R>();

    return function (this: This, ...args: Args): R {
      const key = JSON.stringify(args);
      if (cache.has(key)) return cache.get(key)!;
      const r = value.apply(this, args);
      cache.set(key, r);
      return r;
    };
  };
}

class A {
  @Memoize()
  calc(x: number, y: number) {
    return x + y;
  }
}

데코레이터 팩토리는 “반환 함수의 시그니처를 정확히”가 핵심입니다. 이게 안 되면 TS는 호출 표현식 @Memoize() 자체를 해석하지 못합니다.

마이그레이션 체크리스트(실전)

  1. 현재 프로젝트가 legacy decorators 기반인지(NestJS/TypeORM 등) 먼저 확인
  2. 표준 데코레이터로 갈 경우, 데코레이터 구현을 (value, context)로 전면 수정
  3. 메서드 래핑은 M extends (this: any, ...args) => any 제네릭 패턴으로 시그니처 보존
  4. 필드는 addInitializer 중심으로 설계(메서드처럼 다루지 않기)
  5. context.kind로 분기하고 context.nameString() 처리 습관화

런타임에서 모듈/번들 설정이 흔들리면 데코레이터 이전에 앱이 부팅부터 실패하기도 합니다. ESM/TS 설정이 애매한 상태라면 위에서 언급한 Node.js ESM+TS에서 ERR_MODULE_NOT_FOUND 해결법처럼 모듈 해석부터 먼저 안정화하는 것을 권합니다.

결론

TS 5.5+에서 데코레이터 타입 오류는 대개 “엄격해져서”가 아니라 데코레이터 모델이 바뀌면서(표준 컨텍스트 기반) 우리가 예전 방식으로 코드를 쓰기 때문에 발생합니다. 위 6가지는 특히 마이그레이션 초기에 가장 많이 터지는 패턴이며, 해결책은 공통적으로 다음으로 수렴합니다.

  • 표준 시그니처 준수: (value, context)
  • 시그니처 보존 래핑: Parameters/ReturnType/ThisParameterType 활용
  • 컨텍스트 기반 설계: kind, addInitializer, name 타입 처리

이 패턴만 잡아도 데코레이터 도입/이전 시 타입 오류의 80% 이상은 빠르게 정리됩니다.