한 줄 정의
ThisType은 객체 리터럴 안 메서드에서 this가 무슨 타입으로 잡힐지 지정하는 마커 타입이다. 다른 유틸리티 타입처럼 새 타입을 만들어 돌려주는 게 아니라, 타입체커에게 "이 리터럴 안에서 this는 이 타입이다"라고 신호만 보낸다.
type ObjectDescriptor<D, M> = {
data?: D
methods?: M & ThisType<D & M>
}
ThisType 자신은 속이 빈 인터페이스다. 프로퍼티도 없고 런타임에 하는 일도 없다. 오직 컴파일러가 이 타입을 특별 취급해서, 이게 붙은 자리의 객체 리터럴 안 메서드들의 this 타입을 결정하는 데 쓴다.
작동 원리
표준 라이브러리에서 ThisType은 이렇게 정의돼 있다.
interface ThisType<T> { }
정의만 보면 아무 정보도 없는 빈 인터페이스다. 타입 수준의 힘은 컴파일러가 이 이름을 알아보고 특수하게 처리하는 데서 나온다. 객체 리터럴이 컨텍스트 타입(어떤 함수의 인자 자리, 어떤 변수의 선언 타입 등으로 미리 정해진 타입)을 가질 때, 컴파일러는 그 컨텍스트 타입 어딘가에 ThisType<X>가 섞여 있는지 본다. 있으면 그 리터럴 안에서 정의되는 메서드들의 this를 X로 잡는다.
이 메커니즘은 세 조건이 동시에 맞아야 작동한다. 첫째, noImplicitThis 컴파일러 옵션이 켜져 있어야 한다(strict에 포함된다). 둘째, 대상이 객체 리터럴이어야 한다 — 이미 선언된 클래스나 인터페이스에는 적용되지 않는다. 셋째, 그 리터럴이 컨텍스트 타입을 통해 typing되고 있어야 한다. 리터럴을 변수에 그냥 담아 타입 추론만 시키면 컨텍스트 타입이 없으니 ThisType이 낄 자리가 없다.
function makeObject<D, M>(desc: { data?: D; methods?: M & ThisType<D & M> }): D & M {
const data: object = desc.data ?? {}
const methods: object = desc.methods ?? {}
return { ...data, ...methods } as D & M
}
const obj = makeObject({
data: { x: 0, y: 0 },
methods: {
moveBy(dx: number, dy: number) {
this.x += dx // this: { x: number; y: number } & { moveBy(...): void }
this.y += dy
},
},
})
methods 필드의 타입이 M & ThisType<D & M>이라, makeObject에 넘긴 객체 리터럴의 methods 자리는 이 타입으로 컨텍스트 typing된다. 컴파일러가 그 안에서 ThisType<D & M>을 발견하고, moveBy 같은 메서드 안 this를 D & M(넘겨준 data와 methods를 합친 타입)으로 잡는다. this.x, this.y가 에러 없이 통과하는 이유다.
쓰는 법
ThisType은 직접 값을 만들거나 변수 타입으로 쓰는 자리가 아니다. 다른 타입의 인터섹션(&) 항으로 끼워 넣어, 그 타입이 컨텍스트 타입으로 걸리는 객체 리터럴의 this를 지정하는 용도로만 쓴다.
type WithThis<T> = T & ThisType<T>
function define<T>(obj: WithThis<T>): T {
return obj
}
obj 매개변수의 타입이 T & ThisType<T>니, define에 넘긴 리터럴 안 모든 메서드에서 this는 T로 잡힌다 — 그 리터럴이 최종적으로 갖게 될 타입 자신이다. tsconfig에 noImplicitThis(또는 strict)가 꺼져 있으면 이 지정 자체가 무시되고 this는 예전처럼 any로 샌다.
실전 예제
Vuex 스타일 스토어처럼, state 필드와 그 state를 조작하는 메서드를 한 객체 리터럴로 같이 정의하는 API에서 ThisType이 실제로 쓰인다. 메서드 안에서 state 필드를 this로 직접 참조하고 싶을 때, 타입 정의 쪽에서 ThisType 하나로 그 접근을 허용한다.
type StoreDescriptor<S, A> = {
state: S
actions: A & ThisType<S & A>
}
function createStore<S, A>(desc: StoreDescriptor<S, A>): S & A {
return { ...desc.state, ...desc.actions }
}
const counter = createStore({
state: { count: 0 },
actions: {
increment(step: number) {
this.count += step // this: { count: number } & actions
},
reset() {
this.count = 0
},
},
})
counter.increment(3)
actions 안 두 메서드 모두 this로 count에 접근한다. StoreDescriptor 정의에서 A & ThisType<S & A>가 그 접근을 타입 수준에서 보장한다 — state 필드 이름을 바꾸면 increment, reset 안 this.count 참조도 그 자리에서 타입 에러로 걸린다.
쓰는 이유
ThisType은 잘 안 보이지만, 데이터와 그 데이터를 다루는 메서드를 한 객체 리터럴로 합쳐 넘기는 API를 타입 안전하게 설계할 때 유일한 도구다. Vue 2의 컴포넌트 옵션, 여러 상태관리 라이브러리의 스토어 정의처럼 "이 안에서 this는 나 자신이다"라는 패턴은 자바스크립트 생태계에 흔하고, 그런 API의 .d.ts를 이해하거나 직접 만들 때 ThisType 없이는 this를 any로 방치하거나 매개변수마다 상태를 넘겨받는 번거로운 형태로 설계를 바꿔야 한다. noImplicitThis 아래에서 암시적 this를 전부 막아 놓고, 이 한 군데만 명시적으로 열어주는 예외 통로가 ThisType이다.
Reference