•
타입스크립트에서 의존성이 어떻게 동작하는지 설명하여 의존성에 대한 개념을 잡을 수 있게 한다.
•
의존성 관리를 하다가 맞닥뜨릴 수 있는 몇 가지 문제를 보여주고 해결하는 방법을 찾아본다.
item 45. devDependencies에 typescript와 @types 추가하기
•
npm은 자바스크립트 라이브러리 저장소와 프로젝트가 의존하고 있는 라이브러리들의 버전을 지정하는 방법을 제공한다.
•
npm은 세 가지 종류의 의존성을 구분해서 관리한다.
◦
dependencies
▪
현재 프로젝트를 실행하는 데 필수적인 라이브러리들이 포함된다.
▪
프로젝트의 런타임에 lodash가 사용된다면 dependencies에 포함되어야 하며 lodash에 들어 있는 라이브러리들도 함께 설치된다.
•
이러한 현상을 전이 의존성이라고 한다.
◦
devDependencies
▪
현재 프로젝트를 개발하고 테스트하는 데 사용되지만 런타임에는 필요 없는 라이브러리들이 포함된다.
▪
예를 들어, 프로젝트에서 사용 중인 테스트 프레임워크가 devDependencies에 포함될 수 있는 라이브러리다.
◦
peerDependencies
▪
런타임에 필요하긴 하지만 의존성을 직접 관리하지 않는 라이브러리들이다.
▪
단적인 예로 플러그인을 들 수 있다.
•
세 가지 의존성 중에서 dependencies와 devDependencies가 주로 사용되며 라이브러리를 추가할 때 어떤 종류의 의존성을 사용해야하는지 이해하고 있어야 한다.
◦
타입스크립트는 개발 도구일 뿐 타입 정보는 런타임에 존재하지 않기 때문이며 타입스크립트와 관련된 라이브러리는 일반적으로 devDependencies에 속한다.
•
모든 타입스크립트 프로젝트에서는 공통적으로 고려해야할 의존성 두 가지를 살펴보겠다.
◦
타입스크립트 자체 의존성을 고려해야 한다. 타입스크립트를 시스템 레벨로 설치할 수 있지만 다음 두 이유로 인해 추천하진 않는다.
▪
팀원들 모두가 항상 동일한 버전을 사용할거란 보장이 없다.
▪
프로젝트 환경 설정 시 별도의 단계가 추가된다.
◦
타입 의존성을 고려해야 한다.
▪
사용하려는 라이브러리에 타입 선언이 존재하지 않아도 타입 정보를 얻을 수 있다.
•
@types/jquery에는 jquery의 타입 정의가 있고 @types/lodash에는 lodash의 타입 정의가 있다.
•
요약
◦
타입스크립트를 시스템 레벨로 설치해선 안 된다. 타입스크립트를 프로젝트의 devDependencies에 포함시키고 팀원 모두가 동일한 버전을 사용하도록 해야 한다.
◦
@types 의존성은 dependencies가 아니라 devDependencies에 포함시켜야 한다. 런타임에 @types가 필요한 경우, 별도의 작업이 필요할 수 있다.
item 46. 타입 선언과 관련된 세 가지 버전 이해하기
•
의존성 관리는 그 자체로도 충분히 복잡한 작업이므로 라이브러리의 전이적 의존성이 호환되는지 깊게 생각하지 않았을 것이다.
•
타입스크립트를 사용하면 다음 세 가지 사항을 추가로 고려해야 하며 의존성 문제를 알아서 해결해주지 않고 오히려 더 복잡하게 만든다.
◦
라이브러리의 버전
◦
타입 선언(@types)의 버전
◦
타입스크립트의 버전
•
세 버전 중 하나라도 맞지 않으면 의존성과 상관이 없어보이는 곳에서 오류가 발생할 수 있다.
◦
이런 오류들의 원인을 파악하고 고치기 위해서는 타입스크립트 라이브러리 관리의 메커니즘을 이해하고 있어야 한다.
•
타입스크립트에서 의존성을 사용하는 방식은 다음과 같은데, 특정 라이브러리를 dependencies로 설치하고, 타입 정보는 devDependencies로 설치한다.
$ npm install react
+ react@16.8.6
$ npm install --save-dev @types/react
+ @types/react@16.8.19
Shell
복사
◦
위 예시의 경우 메이저 버전과 마이너 버전이 일치하지만 패치 버전은 일치하지 않는다.
◦
물론 시맨틱 버전 규칙을 적절히 잘 지키고 있다면 두 버전이 일치하므로 큰 문제가 없겠지만 타입 선언 자체에도 버그나 누락이 존재할 수 있으므로 불일치가 발생할 가능성은 얼마든지 있다.
•
이처럼 실제 라이브러리와 타입 정보의 버전이 별도로 관리되는 방식은 다음 네 가지 문제점을 야기한다.
◦
라이브러리를 업데이트했지만 타입 선언은 업데이트하지 않는 경우
▪
이런 경우, 업데이트된 기능을 사용하려할 때마다 타입 오류가 발생하게 된다.
▪
하위 호환성이 깨지는 변경이 업데이트에서 발생했다면 코드가 타입 체커를 통과하더라도 런타임에 오류가 발생할 수 있다.
▪
해결책으로 타입 선언도 업데이트하는 방법이 있으며 만약 타입 선언 버전이 준비되어있지 않다면 다음 두 가지 해결책을 추가로 고려해볼 수 있다.
•
보강 기법을 활용하여 사용하려는 새 함수와 메소드의 타입 정보를 프로젝트 자체에 추가해두는 방법
•
타입 선언의 업데이트를 직접 작성하고 공개하여 커뮤니티에 기여하는 방법
◦
라이브러리보다 타입 선언의 버전이 최신인 경우
▪
보통 타입 정보없이 라이브러리를 사용해 오다가 타입 선언을 설치하려고 할 때 발생한다.
▪
타입 체커는 최신 API를 기준으로 코드를 검사하게 되지만 런타임에 실제로 사용하는 것은 과거 버전으로 불일치 문제가 발생한다.
▪
해결책은 라이브러리와 타입 선언의 버전이 맞도록 라이브러리 버전을 올리거나 타입 선언의 버전을 낮추는 것이다.
◦
프로젝트에서 사용하는 타입스크립트 버전보다 라이브러리에서 필요로 하는 타입스크립트 버전이 최신인 경우
▪
일반적으로 로대시, 리액트, 람다같은 유명 자바스크립트 라이브러리의 타입 정보를 더 정확하게 표현하기 위해 타입 시스템이 개선되고 버전이 상승한다.
•
이들의 최신 버전을 사용하려면 당연히 타입스크립트 최신 버전을 사용해야 한다.
▪
현재 프로젝트보다 라이브러리에게 필요한 타입스크립트 버전이 높은 상황이라면 @types 선언 자체에서 타입 오류가 발생하게 된다.
▪
이 오류를 해결하려면 타입스크립트 버전을 올리거나 타입 선언의 버전을 원래대로 내리면 된다.
▪
타입스크립트의 특정 버전에 대한 타입 정보를 설치하려면 다음과 같이 작성하면 된다.
$ npm install --save-dev @types/lodash@ts3.1
▪
라이브러리와 타입 선언의 버전을 일치시키는 것이 최선이겠지만 상황에 따라 해당 버전의 타입 정보가 존재하지 않을 수 있다. 그러나 유명한 라이브러리라면 존재할 가능성이 크다.
◦
@types 의존성이 중복되는 경우
▪
만약 @types/foo와 @tpyes/bar에 의존하고 있는 상황에서 @types/bar가 현재 프로젝트와 호환되지 않는 @types/foo 버전에 의존한다면 npm은 중첩된 폴더에 별도로 해당 버전을 설치하여 문제를 해결하려 시도한다.
▪
런타임에 사용되는 모듈이라면 괜찮을 수 있지만 전역 네임스페이스에 있는 타입 선언 모듈이라면 문제가 발생한다.
▪
이런 경우 보통 @types/foo을 업데이트하거나 @tpyes/bar를 업데이트해서 서로 버전이 호환되도록 한다.
•
이처럼 @types가 전이 의존성을 가지도록 만드는 것은 종종 문제를 일으키기도 한다.
•
일반적으로 타입스크립트로 작성된 라이브러리들은 자체적으로 타입 선언을 포함하고 있다.
◦
자체적인 타입 선언은 보통 package.json의 types 필드에서 .d.ts를 가리키도록 되어 있다.
•
라이브러리가 타입 선언을 포함하는 경우, 다음과 같은 네 가지 문제점을 가지고 있다.
◦
포함된 타입 선언에 보강 기법으로 해결할 수 없는 오류가 있는 경우, 공개 시점에 잘 동작했지만 타입스크립트 버전이 올라가면서 오류가 발생하는 경우
▪
이 경우, 타입 선언이 포함되어 있으므로 @types의 버전 선택이 불가능하다..
◦
프로젝트 내의 타입 선언이 다른 라이브러리의 타입 선언에 의존하는 경우
▪
보통은 의존성이 devDependencies에 존재하는데, 프로젝트를 배포하는 경우, 해당 의존성은 공개되지 않아 프로젝트를 다운받는 다른 사용자들은 타입 오류를 맞이하게 된다.
◦
프로젝트의 과거 버전에 있는 타입 선언에 문제가 있는 경우
▪
과거 버전으로 돌아가서 패치 업데이트를 수행해야 한다.
◦
타입 선언의 패치 업데이트를 자주 하기 어렵다.
▪
앞서 살펴보면 react 예제에서 라이브러리 자체보다 타입 선언에 대한 패치 업데이트가 13번이나 더 발생했다.
▪
독립적이기 때문에 개별로 업데이트가 어렵지 않다.
•
잘 작성된 타입 선언은 라이브러리를 올바르게 사용하는데 도움을 주며 생산성 역시 크게 향상시킨다.
•
요약
◦
@types 의존성과 관련된 세 가지 버전이 있다.
▪
라이브러리 버전, @types 버전, 타입스크립트 버전
◦
라이브러리를 업데이트하는 경우, 해당 타입 선언도 업데이트해야 한다.
◦
타입 선언을 라이브러리에 포함하는 경우와 DefinitelyTyped에 공개하는 것 사이의 장단점을 이해해야 한다.
▪
타입스크립트로 작성된 라이브러리라면 타입 선언을 자체적으로 포함하는 것이 낫다.
▪
자바스크립트로 작성된 라이브러리라면 타입 선언을 DefinitelyType에 공개하는 것이 낫다.
item 47. 공개 API에 등장하는 모든 타입을 익스포트하기
•
서드파티의 모듈에서 익스포트되지 않은 타입 정보가 필요한 경우, 타입 간의 매핑을 해주는 도구를 사용하면 된다.
interface SecretName {
first: string;
last: string;
}
interface SecretSanta {
name: SecretName;
gift: string;
}
export function getGift(name: SecretName, gift: string): SecretSanta {
// ...
}
TypeScript
복사
•
위와 같이 어떤 타입을 숨기고 싶어서 익스포트하지 않은 경우, 해당 라이브러리 사용자는 SecretName 또는 SecretSanta를 직접 임포트할 수 없고 getGift만 임포트 가능하다.
•
그러타 타입들은 export된 함수 시그니처에 등장하므로 추출해낼 수 있는데 다음처럼 Parameters와 ReturnType 제네릭 타입을 사용하면 된다.
type MySanta = ReturnType<typeof getGift>; // SecretSanta
type MyName = Parameters<typeof getGift>[0]; // SecretName
TypeScript
복사
•
공개 메소드에 등장한 어떤 형태의 타입이든 익스포트하는 것이 사용자에게 더 편의성을 제공해준다.
item 48. API 주석에 TSDoc 사용하기
•
다음처럼 인사말을 생성하는 타입스크립트 함수가 있다고 해보자.
// Generate a greeting. Result is formatted for display.
function greet(name: string, title: string) {
return `Hello ${title} ${name}`;
}
TypeScript
복사
◦
주석을 통해 함수가 어떤 기능을 수행하는지 쉽게 알 순 있다. 하지만 사용자를 위한 문서라면 JSDoc 스타일의 주석으로 만드는 것이 좋다.
/** Generate a greeting. Result is formatted for display. */
function greetJSDoc(name: string, title: string) {
return `Hello ${title} ${name}`;
}
TypeScript
복사
◦
이렇게 대부분의 편집기는 함수가 호출되는 곳에서 함수에 붙어 있는 JSDoc 스타일의 주석을 툴팁으로 표시해주기 때문이다.
•
외에도 @param과 @returns와 같은 일반적인 규칙을 사용할 수도 있다.
◦
타입스크립트 관점에서는 TSDoc이라고 부르기도 한다.
/**
* Generate a greeting.
* @param name Name of the person to greet
* @param salutation The person's title
* @returns A greeting formatted for human consumption.
*/
function greetFullTSDoc(name: string, title: string) {
return `Hello ${title} ${name}`;
}
TypeScript
복사
◦
타입 정의에 TSDoc을 사용할 수도 있다.
/** A measurement performed at a time and place. */
interface Measurement {
/** Where was the measurement made? */
position: Vector3D;
/** When was the measurement made? In seconds since epoch. */
time: number;
/** Observed momentum */
momentum: Vector3D;
}
TypeScript
복사
◦
TSDoc은 마크다운 형식을 지원하므로 굵기, 기울임 등이 가능하다.
•
훌륭한 주석은 산문 형식이 아니다. 간단히 요점만 언급하자.
•
JSDoc에도 타입 정보를 명시하는 규칙이 존재하지만 타입스크립트에서는 타입 정보가 코드에 존재하기 때문에 TSDoc을 작성하는 경우, 타입 정보를 기재해서는 안 된다.
•
요약
◦
export된 함수, 클래스, 타입에 주석을 달아야할 때 JSDoc/TSDoc 형태를 사용하자.
▪
편집기가 호출되는 곳에서 주석 정보를 툴팁으로 제공한다.
◦
@param, @returns 구문을 사용할 수 있으며 또한 문서 서식을 위해 마크다운을 사용할 수 있다.
◦
TSDoc의 경우, 주석에 타입 정보를 포함하면 안 된다.
item 49. 콜백에서 this에 대한 타입 제공하기
•
let이나 const로 선언된 변수는 블록, 정적 범위인 반면 this는 동적 범위로 정의된 방식이 아닌 호출된 방식에 따라서 달라진다.
◦
var 키워드는 함수 범위로 생성된 함수에서만 사용할 수 있으며 함수 내부에서 생성되지 않은 경우엔 전역 범위를 가지게 된다.
◦
렉시컬 스코프는 정적 스코프라고도 하며 중첩된 함수 그룹에서 내부 함수가 상위 범위의 변수 및 기타 리소스에 액세스할 수 있음을 의미한다.
▪
함수 선언 시점에 상위 범위가 결정되어 해당 변수에 접근가능하다.
•
this는 전형적으로 객체의 현재 인스턴스를 참조하는 클래스에서 가장 많이 사용된다.
class C {
vals = [1, 2, 3];
logSquares() {
for (const val of this.vals) {
console.log(val * val);
}
}
}
const c = new C();
c.logSquares();
// output
1
4
9
TypeScript
복사
•
만약 logSquares를 외부 변수에 추가하고 호출하면 어떻게 될까
const c = new C();
const method = c.logSquares;
// Uncaught TypeError: Cannot read property 'vals' of undefined
method();
TypeScript
복사
◦
c.logSquares()가 실제로는 두 가지 작업을 수행하기 때문에 문제가 발생한다. C.prototype.logSquares를 호출하고 this의 값을 c로 바인딩한다.
◦
이 코드에서 logSqaures의 참조 변수를 사용하므로 두 작업을 분리하였고 this의 값은 undefined로 설정된다.
⇒ 근데 이런식이면 static을 사용하는게 더 나을것 같은데..
•
콜백 함수에서 this 값을 사용해야한다면 this는 API의 일부로써 명시되어야 하므로 타입 선언에 반드시 포함되어야 한다.
•
Node에서 this는 module.exports인데, 파일을 모듈로 사용할 수 있게 해주는 객체다.
•
요약
◦
자바스크립트에서 this 바인딩이 동작하는 원리를 이해해야 한다.
◦
콜백 함수에서 this 매개변수를 사용해야 한다면 타입 정보를 명시해야 한다.
item 50. 오버로딩 타입보다는 조건부 타입을 사용하기
•
다음 함수에 타입 정보를 추가해보자.
function double(x) {
return x + x;
}
TypeScript
복사
◦
이 함수에는 string 또는 number 타입의 매개변수가 들어올 수 있으므로 다음과 같이 유니온 타입(string | number)을 추가할 수 있다.
◦
이는 string 타입을 매개변수로 넣어도 number 타입을 반환하는 경우도 포함되어있다.
◦
이는 제네릭을 사용해서 좁혀볼 수 있다.
function double<T extends number|string>(x: T): T;
function double(x: any) { return x + x; }
const num = double(12); // Type is 12
const str = double('x'); // Type is "x"
TypeScript
복사
◦
이 경우 타입이 과하게 구체적이다.
•
여러 타입 선언으로 분리하는 방법도 있다.
function double(x: number): number;
function double(x: string): string;
function double(x: any) { return x + x; }
const num = double(12); // Type is number
const str = double('x'); // Type is string
TypeScript
복사
•
함수 타입이 명확해졌지만 문제가 발생한다. string이나 number 타입의 값으로는 문제없이 동작하지만 유니온 타입 관련해서 문제가 발생한다.
◦
오버로딩 타입을 하나 더 추가해서 문제를 해결할 수도 있지만 가장 좋은 해결책은 조건부 타입을 사용하는 것이다.
•
조건부 타입은 타입 공간의 if 구문과 유사하다.
function double<T extends number | string>(
x: T
): T extends string ? string : number;
function double(x: any) { return x + x; }
TypeScript
복사
◦
이는 제네릭을 사용한 예제와 유사하지만 반환 타입이 덜 구체적이고 정교하게 추론된다.
◦
조건부 타입은 자바스크립트의 삼항 연산자처럼 사용하면 된다.
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);
}
TypeScript
복사
•
유니온에 조건부 타입을 적용하면 조건부 타입의 유니온으로 분리되기 대문에 number|string의 경우에도 동작한다.
◦
만약 T가 number|string이면 타입스크립트는 조건부 타입을 다음 단계로 해석한다.
(number|string) extends string ? string : number
-> (number extends string ? string : number) |
(string extends string ? string : number)
-> number | string
TypeScript
복사
•
오버로딩 타입이 작성하기는 쉽지만 조건부 타입은 개별 타입의 유니온으로 일반화하기 때문에 타입이 더 정확하게 추론된다.
◦
오버로딩의 경우, 타입이 독립적으로 처리된다.
◦
조건부 타입은 타입 체커가 단일 표현식으로 받아들이기 때문에 유니온 문제를 해결할 수 있다.
•
오버로딩 타입을 사용하고 있다면 조건부 타입을 사용해서 개선할 수 있을지 검토해보는 것도 좋다.
•
요약
◦
오버로딩 타입보다 조건부 타입을 사용하는 것이 좋다. 조건부 타입은 추가적인 오버로딩 구문없이 유니온 타입을 지원할 수 있다.
item 51. 의존성 분리를 위해 미러 타입 사용하기
•
CSV 파일을 파싱하는 기능을 제공하는 프로젝트를 배포한다고 가정해보자.
•
이때 반환값을 특정 NodeJS에 의존하는 Buffer로 선언했다면 이는 devDependencies에 포함되고 이는 다음 두 그룹의 사용자에게 문제를 야기한다.
◦
@types와 무관한 자바스크립트 개발자
◦
NodeJS와 무관한 타입스크립트 개발자
•
두 그룹은 이 라이브러리를 이용하기 위해 다른 라이브러리를 사용하거나 타입 선언을 사용해야하므로 혼란스러울 것이다.
•
이를 해결하기 위해 구조적 타이핑을 사용하면 된다.
interface CsvBuffer {
toString(encoding?: string): string;
}
function parseCSV(contents: string | CsvBuffer): {[column: string]: string}[] {
// ...
}
TypeScript
복사
◦
위와 같이 @types/node에 있는 Buffer 선언을 사용하지 않고 필요한 메소드와 속성만 따로 작성하여 대체할 수 있다.
•
요약
◦
필수가 아닌 의존성을 분리하는 경우, 구조작 타이핑을 사용하라.
◦
공개한 라이브러리를 사요하는 자바스크립트 사용자가 @types 의존성을 가지지 않게 하며 웹 개발자가 NodeJS 의존성을 가지지 않게 해야 한다.
item 52. 테스팅 타입의 함정에 주의하기
•
타입스크립트의 타입은 동적이기 때문에 라이브러리의 기능을 제공할 때, 충분한 테스트를 통해 의도된 타입을 제공하는지 확인해야 한다.
•
일반적으로 변수를 도입하는 대신 헬퍼 함수를 정의하여 이를 수행할 수 있다.
function assertType<T>(x: T) {}
assertType<number[]>(map(['john', 'paul'], name => name.length));
TypeScript
복사
◦
다만 해당 코드는 두 타입이 동일한지 체크하는 것이 아니라 할당 가능성을 체크하고 있다. 따라서 다음과 같은 경우도 정상이다.
const n = 12;
assertType<number>(n); // OK
TypeScript
복사
◦
그러나 객체의 타입을 체크하는 경우 다음과 같이 문제가 발생할 수 있다.
const beatles = ['john', 'paul', 'george', 'ringo'];
assertType<{name: string}[]>(
map(beatles, name => ({
name,
inYellowSubmarine: name === 'ringo'
}))); // OK
TypeScript
복사
◦
이 코드에서 map은 {name: string, inYellowSubmarine: boolean} 객체의 배열을 반환하는데, 이는 {name: string}[]에 할당가능하지만 inYellowSubmarine 속성에 대한 부분은 체크되지 않는다.
•
Parameters와 ReturnType 제네릭 타입을 사용하여 함수의 매개변수 타입과 반환 타입만 분리하여 테스트하는 것이 좋다.
const double = (x: number) => 2 * x;
let p: Parameters<typeof double> = null!;
assertType<[number, number]>(p);
// ~ Argument of type '[number]' is not
// assignable to parameter of type [number, number]
let r: ReturnType<typeof double> = null!;
assertType<number>(r); // OK
TypeScript
복사
•
요약
◦
타입을 테스트할 때는 함수 타입의 동일성(equality)과 할당 가능성(assignability)의 차이점을 알고 있어야 한다.
◦
콜백이 있는 함수를 테스트할 때, 콜백 매개변수의 추론된 타입을 체크해야 한다. 또한 this가 API의 일부분이라면 역시 테스트해야 한다.
◦
타입 관련된 테스트에서 any를 주의해야 한다. 더 엄격한 테스트를 위해 dtslint 같은 도구를 사용하는 것이 좋다.