콜백에서 this에 대한 타입 제공하기

JS에서의 this 키워드는 매우 혼란스러운 기능입니다. let이나 const로 선언된 변수가 렉시컬 스코프(Lexical Scope)인 반면, this는 다이나믹 스코프(Dynamic Scope)입니다. 이는 정의된 방식이 아니라, 호출된 방식에 따라 가리키는 값이 달라집니다.

this는 전형적으로 객체의 현재 인스턴스를 참조하는 클래스에서 가장 많이 쓰입니다.

class C {
  vals = [1, 2, 3];
  logSquares() {
    for (const val of this.vals) {
      console.log(val * val);
    }
  }
}

const c = new C();
c.logSquares();

// 1
// 4
// 9

위 상황에서 logSquares에서 사용된 this는 변수 c를 가리키게 됩니다. 한편, 이것을 외부 변수에 넣고 호출하면 어떻게 되는지 살펴봅시다.

const c = new C();
const method = c.logSquares; // losing this
method(); // ERROR

이러한 에러가 발생하는 이유는, 사실 c.logSquares의 호출이 실제로는 두 가지 작업을 수행하기 떄문입니다.

  • this의 값을 바인딩합니다.
  • C.prototype.logSquares를 호출합니다.

이를 JS 상에서 온전히 제어하기 위해서는 명시적으로 this를 바인딩해주어야 하는데, 이를 위해 call, apply, bind와 같은 메서드들이 존재합니다.

이러한 this 바인딩은 종종 콜백함수에서 쓰입니다. React의 예시를 봅시다. 다음 예시에서 바인딩을 하지 않는다면 render 메서드 실행 시 thisundefined가 되는 문제가 생깁니다.

class ResetButton {
  constructor() {
    this.onClick = this.onClick.bind(this);
  }
  render() {
    return makeButton({text: 'Reset', onClick: this.onClick});
  }
  onClick() {
    alert(`Reset {this}`);
  
}

화살표 함수를 사용하면 더 쉽게 이를 해결할 수 있습니다. 화살표 함수로 메서드를 변경하면 해당 클래스의 인스턴스의 생성할 때마다 제대로 바인딩된 this를 가진 새 함수를 생성합니다.

class ResetButton {
  render() {
    return makeButton({text: 'Reset', onClick: this.onClick});
  }
  onClick = () => {
    alert(`Reset {this}`);  // "this"가 항상 인스턴스를 참조합니다.
  }
}

이는 실제로는 다음과 같이 동작합니다.

class ResetButton {
  constructor() {
    var _this = this;
    this.onClick = function () { // 인스턴스의 생성 시점에 `onClick` 함수를 선언합니다.
      alert("Reset " + _this);
    };
  }
  render() {
    return makeButton({text: 'Reset', onClick: this.onClick});
  }
}

TS에서 this가 사용되는 콜백 함수를 다루기

콜백 함수 상에서 this가 사용된다면 그 자체가 API의 일부가 되는 것이기 때문에 반드시 타입 선언에 포함되어야 합니다.

// 콜백함수인 `fn` 내부에서 `this`를 사용한다고 가정합시다.
function addKeyListener(
  el: HTMLElement,
  fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
  el.addEventListener('keydown', e => {
    fn.call(el, e);
  });
}

이 때 해당 콜백 함수 타입의 첫 번째 매개변수에 있는 this는 실제론 사용 시점에는 매개변수로 여겨지지 않으며, 특별하게 처리됩니다. 만약 해당 콜백 함수를 this 바인딩 없이 그냥 실행하려고 하는 경우에 에러를 출력하게끔 하여, 바인딩을 강제하도록 합니다.

function addKeyListener(
  el: HTMLElement,
  fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
  el.addEventListener('keydown', e => {
    fn(e); // The 'this' context of type 'void' is not assignable to method's 'this' of type 'HTMLElement'.(2684)
    fn(el, e); // Expected 1 arguments, but got 2.(2554)
    fn.call(el, e); // OK.
  });
}

또, this를 사용하는 해당 콜백함수를 작성하는 시점에 this에 대한 타입이 명확하게 추론되기 때문에 타입 안정성을 확보할 수 있습니다.

const el = document.getElementById('div')!;
addKeyListener(el, function(e) {
  // 앞선 타입 선언에 덕분에 알아서 `this`에 대한 타입을 추론합니다.
  this.innerHTML; // this는 HTMLElement 입니다.
});

만약 화살표 함수를 사용하여 this를 참조하려고 하는 경우, 해당 this는 다른 것을 가리킬 것이기 때문에 적절히 에러를 출력해냅니다.

class Foo {
  registerHandler(el: HTMLElement) {
    addKeyListener(el, e => {
      // 여기서의 `this`는 Foo의 인스턴스가 됩니다.
      this.innerHTML;
        // ~~~~~~~~~ Property 'innerHTML' does not exist on type 'Foo'
    });
  }
}