49. 콜백에서 this에 대한 타입 제공하기
let/const로 선언된 변수가 렉시컬 스코프인 반면, this는 다이나믹 스코프입니다.
다이나믹 스코프의 값은 정의된 방식이 아닌 호출된 방식에 따라 달라진다.
this는 객체의 현재 인스턴스를 참조하는 클래스에서 가장 많이 쓰인다.
class C {
vals = [1, 2, 3];
logSquares() {
for (const val of this.vals) {
console.log(val * val);
}
}
}
const c = new C();
c.logSquares();
const method = c.logSquares();
method(); // undefined의 'vals' 속성을 읽을 수 없습니다.
내부 메서드를 외부 변수에 넣고 호출하면 런타임에 오류가 발생한다.
c.logSquares()는 C.prototype.logSquares를 호출하고, 또한 this의 값을 c로 바인딩한다.
logSquares의 참조 변수를 사용하면 두 가지 작업이 분리되고, this의 값은 undefined로 설정된다.
call을 사용하면 명시적으로 this 바인딩을 제어할 수 있다.
const method = c.logSquares;
method.call(c);
DOM에서도 this를 바인딩할 수 있다.
document.querySelector("input")!.addEventListener("change", function (e) {
console.log(this); // 이벤트가 발생한 input 엘리먼트를 출력
});
this 바인딩을 종종 콜백 함수에서 쓰인다. 클래스 내에 onClick 핸들러를 정의해 보자.
class ResetButton {
render() {
return makeButton({ text: "Reset", onClick: this.onClick });
}
onClick() {
alert(`Reset ${this}`);
}
}
그러나 ResetButton에서 onClick을 호출하면 this 바인딩 문제로 "Reset이 정의되지 않았습니다"라는 경고가 뜬다.
일반적인 해결책은 생성자에서 메서드에 this를 바인딩시키는 것이다.
class ResetButton {
constructor() {
this.onClick = this.onClick.bind(this);
}
render() {
return makeButton({ text: "Reset", onClick: this.onClick });
}
onClick() {
alert(`Reset ${this}`);
}
}
onClick(){}은 ResetButton.ptorotype의 속성을 정의한다. 그러므로 ResetButton의 모든 인스턴스에서 공유된다.
그러나 생성자에서 this.onClick으로 바인딩하면, onClick 속성에 this가 바인딩되어 해당 인스턴스에 생성된다.
lookup sequence에서 onClick 인스턴스 속성은 onClick 프로토타입 속성보다 앞에 놓이므로,
render() 메서드의 this.onClick은 바인딩된 함수를 참조하게 된다.
화살표 함수로 해결할 수도 있다.
class ResetButton {
render() {
return makeButton({ text: "Reset", onclick: this.onClick });
}
onClick = () => {
alert(`Reset ${this}`);
};
}
화살표 함수로 바꾸면 ResetButton이 생성될 때마다 제대로 바인딩된 this를 가지는 새 함수를 생성하게 된다.
자바스크립트가 실제로 생성한 코드는 다음과 같다.
class ResetButton {
constructor() {
var _this = this;
this.onClick = function () {
alert("Reset ") + _this;
};
}
render() {
return makeButton({ text: "Reset", onclick: this.onClick });
}
}
작성 중인 라이브러리에 this를 사용하는 콜백 함수가 있다면 this 바인딩 문제를 고려해야 한다.
콜백 함수의 매개변수에 this를 추가하고, 콜백 함수를 call로 호출해서 해결할 수 있다.
function addKeyListener(
el: HTMLElement,
fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
el.addEventListener("keydown", (e) => {
fn.call(el, e);
});
}
콜백 함수의 첫 번째 매개변수에 있는 this는 특별하게 처리된다.
function addKeyListener(
el: HTMLElement,
fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
el.addEventListener("keydown", (e) => {
fn(el, e); // Expected 1 arguments, but got 2.
});
}
콜백 함수의 매개변수에 this를 추가하면 this 바인딩이 체크된다.
function addKeyListener(
el: HTMLElement,
fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
el.addEventListener("keydown", (e) => {
fn(e);
// 'void' 형식의 'this' 컨텍스트를 메스드의 'HTMLElement' 형식 'this'에 할당할 수 없습니다.
});
}
또한 라이브러리 사용자의 콜백 함수에서 this를 참조할 수 있고 완전한 타입 안정성도 얻을 수 있다.
declare let el: HTMLElement;
addKeyListener(el, function () {
this.innerHTML; // 정상. "this"는 HTMLElement 타입
});
만약 라이브러리 사용자가 콜백을 화살표 함수로 작성하고 this를 참조하려고 하면 타입스크립트가 문제를 잡아낸다.
class Foo {
registerHandler(el: HTMLElement) {
addKeyListener(el, () => {
this.innerHTML; // Property 'innerHTML' does not exist on type 'Foo'.
});
}
}
콜백 함수에서 this를 사용해야 한다면, this는 API의 일부가 되는 것이기 때문에 타입 정보를 명시해야 한다.
50. 오버로딩 타입보다는 조건부 타입을 사용하기
function double(x) {
return x + x;
}
double 함수에는 string 또는 number 타입의 매개변수가 들어올 수 있다.
함수 오버로딩 개념을 사용하여 유니온 타입을 추가해 보자.
function double(x: number | string): number | string;
function double(x: any) {
return x + x;
}
const num = double(12); // string | number
const str = double("x"); // string | number
선언이 틀린 것은 아니지만 모호한 부분이 있다. 제너릭을 사용해 보자.
function double<T extends number | string>(x: T): T;
function double(x: any) {
return x + x;
}
const num = double(12); // 타입이 12
const str = double("x"); // 타입이 "x"
이제는 타입이 너무 과하게 구체적이다. double("x")는 "x"가 아닌 "xx"를 반환한다.
또 다른 방법은 여러 가지 타입 선언으로 분리하는 것이다.
function double(x: number): number;
function double(x: string): string;
function double(x: any) {
return x + x;
}
const num = double(12); // number
const str = double("x"); // string
function f(x: number | string) {
return double(x);
// 'string | number' 형식의 인수는 'string' 형식의 매개변수에 할당될 수 없습니다.
}
함수 타입이 조금 명확해졌지만 유니온 타입 관련해서 문제가 발생한다.
타입스크립트는 오버로딩 타입 중에서 일치하는 타입을 찾을 때까지 순차적으로 검색한다.
가장 좋은 해결책은 조건부 타입을 사용하는 것이다.
조건부 타입은 타입 공간의 if 구문과 같다.
function double<T extends number | string>(
x: T
): T extends string ? string : number;
function double(x: any) {
return x + x;
}
const num = double(12); // number
const str = double("x"); // string
// function f(x? string | number): string | number
function f(x: number | string) {
return double(x);
}
유니온에 조건부 타입을 적용하면, 조건부 타입의 유니온으로 분리되기 때문에 number|string의 경우에도 동작한다.
조건부 타입은 개별 타입의 유니온으로 일반화하기 때문에 타입이 더 정확해진다.
각각의 오버로딩 타입이 독립적으로 처리되는 반면, 조건부 타입은 타입 체커가 단일 표현식으로 받아들인다.
51. 의존성 분리를 위해 미러 타입 사용하기
CSV 파싱 라이브러리를 작성한다고 해보자. NodeJS 사용자를 위해 Buffer 타입을 허용하자.
function parseCSV(contents: string | Buffer): { [column: string]: string }[] {
if (typeof contents === "object") {
// 버퍼인 경우
return parseCSV(contents.toString("utf8"));
}
}
Buffer의 타입 정의는 @types/node로 얻을 수 있다.
그러나 @types/node를 devDependencies로 포함하면 다음 두 그룹의 라이브러리 사용자들이게 문제가 생긴다.
- @types와 무관한 자바스크립트 개발자
- NodeJS와 무관한 타입스크립트 웹 개발자
각자가 필요한 모듈만 사용할 수 있도록 구조적 타이핑을 적용할 수 있다.
인코딩 정보를 매개변수로 받는 toString 메서드를 가지는 인터페이스를 별도로 만들어 사용하자.
interface CsvBuffer {
toString(encoding: string): string;
}
function parseCSV(
contents: string | CsvBuffer
): { [column: string]: string }[] {}
CsvBuffer는 Buffer 인터페이스보다 훨씬 짧으면서도 실제로 필요한 부분만을 떼어 내어 명시했다. 또한 Buffer와 호환된다.
parseCSV(new Buffer("column1,column2\nval1,val2", "uft-8")); // 정상
만약 작성 중인 라이브러리가 의존하는 라이브러리의 구현과 무관하게 타입에 의존한다면,
필요한 선언부만 추출하여 작성 중인 라이브러리에 넣는 미러링을 고려하자.
다른 라이브러리의 타입이 아닌 구현에 의존하는 경우에도 동일한 기법을 적용할 수 있고 타입 의존성을 피할 수 있다.
미러링은 유닛 테스트와 상용 시스템 간의 의존성을 분리하는 데도 유용하다.
프로젝트의 의존성이 다양해지면 미러링 기법을 적용하기가 어려워진다.
다른 라이브러리의 타입 선언의 대부분을 추출해야 한다면 @types 의존성을 추가하는 게 낫다.
52. 테스팅 타입의 함정에 주의하기
타입 선언을 테스트하기는 매우 어렵다. 단언문으로 때우는 방식에는 몇 가지 문제가 있다.
궁극적으로 dtslint 또는 타입 시스템 외부에서 타입을 검사하는 도구를 사용하는 것이 더 안전하고 간단하다.
test("square a number", () => {
square(1);
square(2);
});
이 테스트 코드는 함수의 실행에서 오류가 발생하지 않는지만 체크한다.
반환값에 대해서는 체크하지 않기 때문에 실행의 결과에 대한 테스트는 하지 않은 게 된다.
square의 구현이 잘못되어 있더라도 이 테스트를 통과하게 된다.
반환값을 특정 타입의 변수에 할당하여 반환 타입을 체크할 수 있게 해보자.
const lengths: number[] = map(["john", "paul"], (name) => name.length);
불필요한 타입 선언에 해당하지만, 테스트 코드 관점에서는 중요한 역할을 한다.
number[] 타입 선언은 map 함수의 반환 타입이 number[]임을 보장한다.
그러나 테스팅을 위해 할당을 사용하는 방법에는 두 가지 근본적인 문제가 있다.
1. 불필요한 변수를 만들어야 한다.
반환값을 할당하는 변수는 일부 린팅 규칠(미샤용 변수 경고)를 비활성해야 한다.
변수를 도입하는 대신 헬퍼 함수를 정의하여 해결할 수 있다.
function assertType<T>(x: T) {}
assertType<number[]>(map(["john", "paul"], (name) => name.length));
2. 두 타입이 동일한지 체크하는 것이 아니라 할당 가능성을 체크한다.
const n = 12;
assertType<number>(n); // 정상
n 심벌을 조사해 보면, 타입이 실제로 12다. 12는 number의 서브타입이고 할당 가능성 체크를 통과한다.
객체의 타입을 체크하는 경우 문제가 발생한다.
const beatles = ["john", "paul", "george", "ringo"];
assertType<{ name: string }[]>(
map(beatles, (name) => ({
name,
inYellowSubmarine: name === "ringo",
}))
); // 정상
반환된 배열은 {name: string}[]에 할당 가능하지만, inYellowSubmarine 속성에 대한 부분이 체크되지 않았다.
const double = (x: number) => 2 * x;
assertType<(a: number, b: number) => number>(double); // 정상!?
타입스크립트의 함수는 매개변수가 더 적은 함수 타입에 할당 가능하기 때문에 double 함수의 체크가 성공한다.
제대로 된 asserType 사용 방법은 무엇일까?
Parameters와 ReturnType 제너릭 타입을 이용해 함수의 매개변수 타입과 반환 타입만 분리하여 테스트하자.
const double = (x: number) => 2 * x;
let p: Parameters<typeof double> = null!;
assertType<[number, number]>(p);
// '[number]' 형식의 인수는 '[number, number]' 형식의 매개변수에 할당될 수 없습니다.
let r: ReturnType<typeof double> = null!;
assertType<number>(r); // 정상
앞의 map에 대한 테스트는 모두 블랙박스 스타일이었다.
map의 매개변수로 배열을 넣어 함수를 실행하고 반환 타입을 테스트했지만, 중간 단계의 세부 사항은 테스트하지 않았다.
콜백 함수 내부에서 매개변수들의 타입과 this를 직접 체크해 보자.
declare function map<U, V>(
array: U[],
fn: (this: U[], u: U, i: number, array: U[]) => V
): V[];
const beatles = ["john", "paul", "george", "ringo"];
assertType<number[]>(
map(beatles, function (name, i, array) {
assertType<string>(name);
assertType<number>(i);
assertType<string[]>(array);
assertType<string[]>(this);
return name.length;
})
);
다음 모듈 선언은 까다로운 테스트를 통과할 수 있는 완전한 타입 선언 파일이지만, 전체 모듈에 any 타입을 할당한다.
declare module "overbar";
해당 모듈에 속하는 모든 함수의 호출마다 암시적으로 any 타입을 반환한다.
타입 시스템 내에서 암시적 any 타입을 발견해 내는 것은 어렵다.
그러므로 타입 체커와 독립적으로 동작하는 도구를 사용해서 타입 선언을 테스트하는 방법이 권장된다.
DefinitelyTyped의 타입 선언을 위한 도구는 dtslint이다. 특별한 형태의 주석을 통해 동작한다.
const beatles = ["john", "paul", "george", "ringo"];
map(
beatles,
function (
name, // $ExpectType string
i, // $ExpectType number
array // $ExpectType string[]
) {
this; // $ExpectType string[]
return name.length; // $ExpectType number[]
}
);
dtslint는 할당 가능성을 체크하는 대신 각 심벌의 타입을 추출하여 글자 자체가 같은지 비교한다.
편집기에서 타입 선언을 눈으로 보고 확인하는 과정을 자동화하는 것이다.
그러나 number|string과 string|number는 글자가 달라서 다른 타입으로 인식된다.
string과 any를 비교할 때도 서로 간에 할당이 가능함에도 글자가 달라서 다른 타입으로 인식된다.