- Published on
TS 5.5+ Decorators 적용 시 타입오류 6가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티(예: 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.name은 string | 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() 자체를 해석하지 못합니다.
마이그레이션 체크리스트(실전)
- 현재 프로젝트가 legacy decorators 기반인지(NestJS/TypeORM 등) 먼저 확인
- 표준 데코레이터로 갈 경우, 데코레이터 구현을
(value, context)로 전면 수정 - 메서드 래핑은
M extends (this: any, ...args) => any제네릭 패턴으로 시그니처 보존 - 필드는
addInitializer중심으로 설계(메서드처럼 다루지 않기) context.kind로 분기하고context.name은String()처리 습관화
런타임에서 모듈/번들 설정이 흔들리면 데코레이터 이전에 앱이 부팅부터 실패하기도 합니다. ESM/TS 설정이 애매한 상태라면 위에서 언급한 Node.js ESM+TS에서 ERR_MODULE_NOT_FOUND 해결법처럼 모듈 해석부터 먼저 안정화하는 것을 권합니다.
결론
TS 5.5+에서 데코레이터 타입 오류는 대개 “엄격해져서”가 아니라 데코레이터 모델이 바뀌면서(표준 컨텍스트 기반) 우리가 예전 방식으로 코드를 쓰기 때문에 발생합니다. 위 6가지는 특히 마이그레이션 초기에 가장 많이 터지는 패턴이며, 해결책은 공통적으로 다음으로 수렴합니다.
- 표준 시그니처 준수:
(value, context) - 시그니처 보존 래핑:
Parameters/ReturnType/ThisParameterType활용 - 컨텍스트 기반 설계:
kind,addInitializer,name타입 처리
이 패턴만 잡아도 데코레이터 도입/이전 시 타입 오류의 80% 이상은 빠르게 정리됩니다.