[이펙티브 타입스크립트] 6장 타입 선언과 @types
주요 내용
- 타입스크립트에서 의존성 동작 방식
- 의존성 관리시 발생할 수 있는 문제 및 해결방안
- 타입에 대한 테스팅 기법
아이템45 devDependencies에 typescript와 @types 추가 하기
- dependencies
- 런타임에서 사용하는 라이브러리들이 포함됨
- devDependencies
- 개발 및 테스트에서는 사용되지만, 런타임에는 필요없는 라이브러리들이 포함됨
- typescript, cypress, jest 등 테스트 프레임 워크
- peerDependencies
- 런타임에 필요하지만, 의존성을 직접 관리하지 않는 라이브러리들이 포함됨
- 플러그인
타입스크립트를 시스템 레벨로 설치하기보단 devDependencies로 관리해야하는 이유
- 팀원들과 동일한 타입스크립트 버전 보장
- 프로젝트 셋업시 단계 단축
@types 타입 의존성을 고려 해야함
- @types/react, typescript는 devDependencies에 있어야함.
아이템 46 타입 선언과 관련된 세 가지 버전 이해하기
타입 스크립트 사용시 추가로 고려해야할 사항
- 라이브러리 버전
- 타입 선언(@types) 의 버전
- 타입스크립트의 버전
라이브러리와 @types의 버전을 별도로 관리할 시 문제점
- 라이브러리를 업데이트 했지만, 타입 선언은 업데이트를 하지 않음
- 타입 오류가 발생하거나, 런타임오류가 발생할 수 있음
- 해결책은 타입 선언을 업데이트 하여 라이브러리와 버전을 맞추거나 보강 기법을 활용
- 라이브러리보다 타입 선언의 버전이 최신인 경우
- 타입 체커는 최신 api기준으로 검사하지만, 런타임은 과거 버전
- 해결책은 라이브러리 버전을 올리거나, 타입스크립트 버전을 내룸
- 프로젝트에서 사용하는 타입스크립트 버전보다 라이브에서 필요로하는 타입스크립트 버전이 최신인 경우
- 라이브에서 필요한 타입스크립트 버전이 더 높은경우 @types 선언 자체에서 타입오류가 발생함
- 해결책은 프로젝트의 타입스크립트 버전을 올리거나, 라이브러리의 타입 버전을 내루거나 없엠.
타입 스크립트의 특정 버전에 대한 타입 정보를 설치하는 명령어 (lodash 예시)
$ npm install —save-dev @types/lodash@ts3.1
- @types 의존성이 중복되는 경우
- 런타임에서 사용되는 모듈이라면 괜찮지만, 전역 네임스페이스에 있는 타입 모듈이라면 문제가 발생함
- 중복된 선언 또는 선언이 병합될 수 없다는 오류로 나타나게 됨.
타입 선언 중복이 발생한 위치를 파악하는 명령어
npm ls @types/foo
- 해결책은 @types/foo 혹안 @types/bar를 업데이트해서 서로 버전이 호환되도록 해야함
- DefinitelyTyped란?
- 여러 개발자들이 스스로 자바스크립트 모듈에 타입을 추가하고 있음.
- 타입스크립트로 작성된 라이브러리라면 타입 선언을 보강기법을 이용하여 포함하고, 자바스크립트로 작성된 라이브러리라면 타입 선언을 DefinitelyType을 이용하는 것이 좋다.
아이템 47 공개 API에 등장하는 모든 타입을 export 하기
interface SecretName {
first: string;
last: string;
}
interface SecretSanta {
name: SecretName;
gift: string;
}
export function getGift(name: SecretName, gift: string): SecretSanta {
/// ...
}
이런식으로 됭어있다면 사용자는 SecretName, SecretSanta 를 import 할 수 없고, getGift만 import 가능하다.
해당 두 타입을 추출해서 사용하려면 아래와 같이 사용해야한다.
type MySanta = ReturnType<typeof getGift>; //SecretSanta
type MyName = Parameters<typeof getGift>[0]; // SecretName
Parameters를 이용해서 뽑아낼 수 있는건 처음 알았다.
이렇게 사용할 필요없이 SecretName, SecreteSanta를 사용자를위해 공개 API의 매개변수타입은 export로 만들자.
아이템 48 API 주석에 TSDoc 사용하기
권장되지 않는 예시
// 인사말을 생성합니다.
function greet(name: string, title: string){
return `hello ${title} ${name}`;
}
권장되는 예시
/** 인사말을 생성합니다. */
function greet(name: string, title: string){
return `hello ${title} ${name}`;
}
/**
* 인사말을 생성합니다.
* @param name 인사할 사람의 이름
* @param title 그 사람의 칭호
* @returns 사람이 보기 좋은 형태의 인사말
*/
function greetFullTSDoc(name: string, title: string) {
return 'Hello ${title} ${name}';
}
타입스크립트가 JsDoc 스타일을 지원하기때에 @param과 @returns를 이용하여 적극적으로 활용하자.
타입 정보는 명시하지 않는다.
마크다운형식을 활용할 수도 있으나 쓸대없이 길게 쓰지 않도록 주의하자.
언젠가 내가 만든 라이브러리를 패키지화 해서 업로드 하게 될 일이 있다면 써먹으면 좋을 듯 하다.
아이템49 콜백에서 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();
코드의 출력 결과는 아래와 같다.
1
4
9
onst c = new C();
const method = c.logSquares;
method()
코드의 출력 결과는 [Uncaught TypeError: undefined의 'vats' 속성을 읽을 수 없습니다.]
왜 이렇게 발생하게될까?
c.logSquares()의 하는 역할은 크게 두가지 이다.
1. C.prototype.logSquares 호출
2. this의 값을 c로 바인딩함
허나 두번째 예시는 logSquares의 참조 변수를 사용함으로써 1,2 작업을 분리했고, this 값은 undefined로 설정됨.
해결 방안은 call을 이용하면 된다.
const c = new C();
const method = c.logSquares;
method.call(c);
함수를 호출하면서 this 를 바인딩 하기 때문이다.
call vs apply vs bind
call 과 apply는 this를 바인딩하면서 함수를 호출하고, apply는 매개변수를 배열형태로 담는다.
bind는 this를 바인딩하지만 함수를 호출하지 않는다.
this 바인딩을 콜백 함수에서 쓰는 예시
class ResetButton {
render() {
return makeButton({text: 'Reset', onClick: this.onClick});
}
onClick() {
alert(`Reset ${this}`);
}
}
ResetButton에서 onClick을 호출하면 this바인딩 문제로 인해 Reset이 정의되지 않았습니다. 라는 경고가 뜬다는데..
안뜨는데? 책에 오타 있는거 아닌가..흠 원서 확인해봐야겠다.
알맞게 사용한 예시
function addKeyListener2(
el: HTMLElement,
fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
el.addEventListener('keydown', e => {
fn.call(el, e);
})
}
타입체커에서 걸림
function addKeyListener(
el: HTMLElement,
fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
el.addEventListener('keydown', e => {
fn(el, e);
// ~ 1개의 인수가 필요한데 2개를 가져왔습니다.
});
}
- 콜백함수에서 this를 사용한다면 타입 정보를 명시해야 한다.
아이템49 는 100프로 이해하지 못한 느낌이다. 스터디원들과 다시 논의해보자.
아이템50 오버로딩 타입보다는 조건부 타입을 이용하기
해당 함수에서 타입에 number를 추가하고 싶을경우
function double(x) {
return x + x;
}
- 제너릭을 이용한다. (안 좋은 예시)
function double<T extends number|string>(x: T): T;
책 예시에서 원하는건 x를 넣으면 string을 얻기를 원한다.
const num = double2('x'); // 타입이 x
- 타입 선언을 여러개 만든다. (안 좋은 예시)
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'); // 타입이 strin
타입은 원하는대로 되었지만 유니온 타입관련해서는 버그가 여전히 발생한다.
function f(x: number|string) {
return double(x);
// ~ string | number' 형식의 인수는
// 'string' 형식의 매개변수에 할당될 수 없습니다.
}
작동 원리는 double함수를 호출하게되면 오버로딩 타입 중에서 일치하는 타입을 찾을때 까지 순차적으로 검색한다.
마지막 선언인 (string) 까지 검사하였을 때 number | string 타입은 string에 할당할 수 없다는 오류를 내뿜는다.
- 조건부 타입을 추가한다.
function double<T extends number | string>(x: T): T extends string ? string : number;
function double(x: any) { return x + x; }
제너릭 + 반환 타입을 삼항연산자를 이용해서 표현했다.
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을 이외에 다른 타입이 추가된다면 extends와 삼항연산자 내 타입이 추가되지 않나 싶다.
- 조건부 타입은 추가적인 오버로딩 없이 유니온 타입을 지원할 수 있다.
아이템51 의존성 분리를 위해 미러 타입 사용하기
예시는 CSV파일을 파싱하는 라이브러리를 작성한다고 가정한다.
function parseCSV(contents: string | Buffer): {[column: string]: string}[] {
if (typeof contents === 'object') {
// 버퍼인 경우
return parseCSV(contents.toString('utf8'));
}
// ...
}
이때 두 그룹이 라이브러리를 사용한다고 가정하자.
- @types와 무관한 자바스크립트 개발자
- NodeJs와 무관한 타입스크립트 웹 개발자
Buffer타입은 NodeJs개발자만 필요로 한다.
각자가 사용하지 않는 모듈이 포함되어있는 경우에는 Buffer 인터페이스와 호환되면서 Buffer가 어떤 타입인지 모르는 사요자도 쓸 수 있도록 인터페이스를 따로 만들어서 사용해야 한다.
interface CsvBuffer {
toString(encoding: string): string;
}
function parseCSV(contents: string | CsvBuffer): {[column: string]: string}[] {
// ...
}
- 작성 중인 라이브러리가 타입에 의존한다면, 필요한 선언부만 추출하여 작성중인 라이브러리에 넣는 것을(미러링) 이라고 한다.
아이템52 테스팅 타입의 함정에 주의하기
라이브러리나 프로젝트를 패키지화해서 공개를 하려면 타입 선언도 테스트를 거쳐야 한다. 허나 이는 매우 어렵다.
(나도 한번도 해본적이 없다)
타입선언이 예상한 타입으로 결과를 내는지 테스트 해보기 위해선 함수를 호출하는 테스트 파일을 작성해야한다.
잘못된 예시
declare function map2<U, V>(array: U[], fn: (u: U) => V): V[];
map2(['2017', '2018'], v=> Number(v));
// 매개변수 오류
map2('2014', v=> Number(v));
map내부의 함수가 단일값 이라면 매개변수에 대한 타입은 잡을 수 있지만 반환값에 대한 체크가 누락되어있음
해결책은 타입선언
const lengths: number[] = map(['john', 'paul'], name => name.length);
map의 반환 타입이 number[]임을 보장한다.
실제로 DefinitelyTyped를 살펴봐도 테스팅을 위해 위의 방식을 많이 사용한 예시가 존재한다고 한다.
할당 가능성 체크시 주의할 점
const add = (a: number, b: number) => a + b;
assertType<(a: number; b: number) => number>(add); // 정상
const double = (x: number) => 2 * x;
assertType<(a: number, b: number) => number>(double); // 정상!??
이건 나도 놀란 사실인데 알고보니 내가 일상생활에서 많이 쓰고 있었다.
타입스크립트의 함수는 매개변수가 더 적은 함수 타입에 할당 가능하기 때문이다.
lodash의 map함수 예시
map(array, (name, index, array) => { ...생략});
콜백함수는 name, index, array중에서 한 두개만 사용이 가능하다. (오히려 세가지 모두 이용하는 경우가 드물다)
만약 매개변수의 수가 맞지 않는 경우까지 체크한다면 매우 많은 곳에서 타입스크립트 + 콜백함수 타입 오류가 발생할 것 이다.
해결 방법
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); // 정상
Parameter와 ReturnType 제너릭을 이용하여 매개변수 타입과 반환 타입을 분리하여 두 번 테스트 한다.
또 this를 이용했을때는 다른 추가적인 문제가 있어서 예시가 나와있고 이건 매개변수의 타입 선언을 이용해서 해결한다.
- 타입 선언을 테스트 하는 것은 어렵지만 반드시 해야하는 작업이다.
- 타입 테스트에서 any를 주의해야하기때문에 dtslint 같은 도구를 사용하는 것이 좋다.
개인적으로 6장에서 내가 몰랐던 부분에 대해 가장 많이 알게된 장 인것 같다.
1. 타입스크립트의 내부 동작이나 라이브러리와 함께 썼을때 주의해야할 점을 알게 되었다.
(라이브러리간 타입이 부딪힐 경우에 해결방법이라던지..)
2. 심심할때마다 DefinitelyTyped git repo를 보면서 어떤식으로 개발자들이 타입선언을 테스트하고,
타입을 정의하는지 살펴봐야겠다.
3. this에 대해서 복습할 수 있었다. (apply, call, bind)
4. 다른 개발자들과 협업시 의존성을 낮추기위해 타입을 정의하는 미러방식에 대해 알게되었다.
5. Parameter를 이용하여 함수 매개변수의 타입을 읽어오는 방식에 대해 알게되었다.