Search
Duplicate
🛥️

2장. 타입스크립트의 타입 시스템

item 6. IDE를 사용하여 타입 시스템 탐색하기

타입스크립트를 설치하면 타입스크립트 컴파일러와 단독으로 실행할 수 있는 타입스크립트 서버를 실행할 수 있다.
보통의 목적은 컴파일러를 실행하는 것이지만 언어 서비스를 제공한다는 점에서 타입스크립트 서버 또한 중요하다.
언어 서비스에는 코드 자동 완성, 명세 검사, 검색, 리팩토링이 포함된다. 보통은 IDE를 통해서 언어 서비스를 사용하는데, 타입스크립트 서버에서 언어 서비스를 제공하도록 하는 것이 좋다.
WebStorm 설정
요약
타입스크립트 언어 서비스를 적극 사용하면 어떻게 타입 시스템이 동작하는지 그리고 타입스크립트가 어떻게 타입을 추론하는지 개념을 잡을 수 있다.
타입스크립트가 동작을 어떻게 모델링하는지 알기 위해 타입 선언 파일을 찾아보는 방법을 터득해야 한다.

item 7. 타입이 값들의 집합이라고 생각하기

런타임에 모든 변수는 자바스크립트 세상의 값으로부터 정해지는 각자 고유한 값을 가진다. 변수에는 다양한 종류의 값을 할당할 수 있다.
반면 코드가 실행되기 전, 즉 타입스크립트가 오류를 체크하는 순간에는 타입을 가지게 된다. 할당 가능한 값들의 집합이 타입이라고 생각하면 된다.
이 집합은 타입의 범위라고 부르기도 한다. 가장 작은 집합은 아무 값도 포함하지 않는 공집합이며 이는 타입스크립트에서 never로 표현된다.
다음으로 가장 작은 집합은 한 가지 값만 포함하는 타입으로 유닛 타입이라고도 불리는 리터럴 타입이다.
두 개 혹은 세 개로 묶으려면 유니온 타입을 사용한다.
유니온 타입은 값 집합들의 합집합을 의미한다.
타입스크립트 오류들 중 할당 가능한이라는 문구를 종종 확인 할 수 있는데, 이를 통해 타입 체커의 주요 역할은 하나의 집합이 다른 집합의 부분 집합인지 검사하는 것이라고 볼 수 있다.
타입스크립트는 구조적 타이핑을 기반으로 하기 때문에 다음과 같은 객체가 있다고 할 때, 어떤 객체가 string으로 할당 가능한 id 속성을 가지고 있다면 그 객체는 해당 타입이다.
interface Type { id: string; }
Java
복사
& 연산자는 피연산 타입의 인터섹션을 계산한다. 언뜻 봤을땐 공통된 속성을 타입으로 가지는 타입이 생성될 것 같다.
그러나 타입 연산자는 인터페이스의 속성이 아닌 값의 집합에 적용되므로 추가적인 속성을 가지는 값도 여전히 해당 타입에 속한다.
따라서 인터섹션 타입의 값은 각 타입 내의 속성을 모두 포함하는 것이 일반적인 규칙이다. 합 연산처럼 동작한다.
조금 더 일반적으로 공집합을 선언하는 방법은 extends 키워드를 사용하는 것이다.
즉, 상속의 의미를 집합의 관점으로 생각하는 것이다.
extends 키워드는 제네릭 타입에서 한정자로도 사용되며 아래의 문맥에서는 ~의 부분 집합을 의미하기도 한다.
function getKey<K extends string>(val: any, key: K) { ... };
TypeScript
복사
string을 상속한다는 기존의 개념에서 이해를 시도하면 조금 어렵지만 집합의 관점에서 생각해보면 쉽게 이해할 수 있다.
타입스크립트 용어와 집합 용어
타입스크립트 용어
집합 용어
never
공집합
리터럴 타입
원소가 1개인 집합
값이 T에 할당 가능
값이 T의 원소
T1T2에 할당 가능
T1T2의 부분 집합
T1T2를 상속
T1T2의 부분 집합
T1 | T2
T1T2의 합집합
T1 & T2
T1T2의 교집합
unknown
전체 집합
요약
타입을 값의 집합으로 생각하면 이해하기 편하다. 이 집합은 유한하거나 무한하다.
타입스크립트 타입은 엄격한 상속 관계가 아니라 겹쳐지는 벤다이어그램으로 표현된다. 두 타입은 서로 서브타입이 아니면서도 겹쳐질 수 있다.
한 객체의 추가적인 속성이 타입 선언에 언급되지 않더라도 그 타입의 원소일 수 있다.
타입 연산은 집합의 범위에 적용된다. A와 B의 &A의 범위와 B의 범위의 &이다. 객체 타입에서는 A & B인 값이 A와 B의 속성을 모두 가짐을 의미한다.
A는 B를 상속, A는 B에 할당 가능, A는 B의 서브타입A는 B의 부분 집합과 같은 의미이다.

item 8. 타입 공간과 값 공간의 심벌 구분하기

타입스크립트의 심벌은 타입 공간이나 값 공간 중의 한 곳에 존재한다.
심벌은 이름이 같더라도 속하는 공간에 따라 다른 것을 나타내므로 혼란스러울 수 있다.
타입 선언(:) 또는 단언문(as) 다음에 나오는 심벌은 타입인 반면 = 다음에 나오는 모든 것은 값이다.
간단하게 각 타입 구문들이 값의 관점에서 해석되는지, 타입 관점에서 해석되는지에 따라 많은것이 달라지므로 이를 주의하자.
요약
타입스크립트 코드를 읽을 때 타입인지 값인지를 구분하는 방법을 터득해야 한다.
모든 값은 타입을 가지지만 타입은 값을 가지지 않는다. typeinterface 같은 키워드는 타입 공간에만 존재한다.
classenum같은 키워드는 타입과 값 두 가지로 사용될 수 있다.
“foo”는 문자열 리터럴이거나 문자열 리터럴 타입일 수 있다. 차이점을 알고 구별할 수 있어야 한다.
typeof, this 그리고 많은 다른 연산자들과 키워드들은 타입 공간과 값 공간에서 다른 목적으로 사용된다.

item 9. 타입 단언보다는 타입 선언을 사용하기

// 타입 선언 const alice: Person = {}; // 타입 단언 const bob = {} as Person
TypeScript
복사
첫 번째 alice: Person은 변수에 타입 선언을 붙여서 그 값이 선언된 타입임을 명시한다.
두 번째 as Person은 타입 단언을 수행한다. 이 경우, 타입스크립트가 추론한 타입이 있더라도 Person 타입으로 간주한다.
타입 단언보다 타입 선언을 사용하는 것이 낫다.
const alice: Person = {}; // Person 유형에 필요한 name 속성이 {} 유형에 없습니다. const bob = {} as Person // 오류 없음
TypeScript
복사
타입 선언은 할당되는 값이 해당 인터페이스나 선언된 타입을 만족하는지 검사한다. 가급적이면 안전성 체크도 되는 타입 선언을 사용하는 것이 좋다.
타입 단언이 필요한 경우는 다음과 같다.
타입 체커가 추론한 타입보다 프로그래머가 판단하는 타입이 더 정확할 때 사용한다.
접미사로 !가 사용되는 경우는 null이 아니라는 단언문으로 해석된다. 접두사의 경우 부정을 나타내지만 접미사의 경우 그 의미가 다르다.
타입 단언 시, 임의의 타입 간에 변환을 할 수는 없으나. A가 B의 부분 집합인 경우 타입 단언문을 이용해 변환할 수 있다.
A 타입의 값을 B 타입으로 변환가능하다.
요약
타입 단언(as Type)보다 타입 선언(: Type)을 사용해야 한다.
화살표 함수의 반환 타입을 명시하는 방법을 터득해야 한다. 타입 선언을 이용해 반환 타입을 명시하자.
타입스크립트보다 타입 정보를 더 잘 알고 있는 상황에서는 타입 단언문과 ! 접미사 단언문을 사용하면 된다.

item 10. 객체 래퍼 타입 피하기

자바스크립트에는 객체 외에도 기본형 값들에 대한 일곱 가지 타입(string, number, boolean, null, undefined, symbol, bigint)가 있다.
기본형들은 불변이며 메소드를 가지지 않는다는 점에서 객체와 구분된다. 그런데 다음과 같은 동작은 뭘까?
‘primitive’.chatAt(3) // “m”
string(기본형)에는 메소드가 없지만 자바스크립트에는 String 객체 타입이 정의되어 있다. 따라서 위와 같은 연산은 다음과 같은 순서를 거쳐 실행된다.
1.
기본형을 String 객체로 래핑한다.
2.
메소드를 호출한다.
3.
래핑한 객체를 버린다.
다른 기본형에도 동일하게 객체 래퍼 타입이 존재한다. 이 래퍼 타입들 덕분에 기본형 값에도 메소드를 사용할 수 있고 정적 메소드도 사용할 수 있다.
런타임의 값은 객체가 아니고 기본형이다. 그러나 기본형 타입은 객체 래퍼에 할당가능하기 때문에 타입스크립트는 기본형 타입을 객체 래퍼에 할당하는 선언을 허용한다.
그러나 기본형 타입을 객체 래퍼에 할당하는 구문은 오해하기 쉽고 굳이 그렇게 할 필요도 없다. 가급적이면 기본형을 타입으로 쓰는 것이 좋다.
요약
기본형 값에 메소드를 제공하기 위해 객체 래퍼 타입이 어떻게 사용되는지 이해해야 한다. 직접 사용하거나 인스턴스를 생성하는 것은 피해야 한다.
타입스크립트 객체 래퍼 타입은 지양하고 대신 기본형 타입을 사용해야 한다.
String 대신 string, Number 대신 number와 같이 말이다.

item 11. 잉여 속성 체크의 한계 인지하기

타입이 명시된 변수에 객체 리터럴을 할당할 때 타입스크립트는 해당 타입의 속성이 존재하는지, 그리고 그 외의 속성은 존재하지 않는지 확인한다.
interface Room { numDoors: number; ceilingHeightFt: number; } const r: Room = { numDoors: 1, ceilingHeightFt: 10, elephant: 'present' }; // 개체 리터럴은 알려진 속성만 지정할 수 있으며 'Room' 형식에 'elephant'가 없습니다.
TypeScript
복사
이는 구조적 타이핑 관점에서 생각하면 아무 문제가 없어야 한다. numDoors도 있고 ceilingHeightFt도 있다.
만약 객체 리터럴을 할당하지 않고 객체를 할당한다면 이는 정상적으로 동작할 것이다.
const obj = { numDoors: 1, ceilingHeightFt: 10, elephant: 'present' }; const r: Room = obj
TypeScript
복사
앞선 예제가 실패한 이유는 구조적 타입 시스템에서 발생할 수 있는 중요한 오류중 하나인 속성 체크 과정이 수행되었기 때문이다.
이러한 잉여 속성 체크는 조건에 따라 동작하지 않는다는 한계가 존재하므로 할당 가능 검사와 동일하다고 인식해버리면 구조적 타이핑에 대한 근본적인 의문이 들게될 것이다.
잉여 속성 체크할당 가능 검사가 별도의 과정이라는 것을 알아야 타입스크립트의 타입 시스템에 대한 개념을 잡을 수 있다.
잉여 속성 체크는 타입 단언문을 사용할 때에도 적용되지 않는다.
const o = { darkmode: true, title: 'Ski Free' } as Options
TypeScript
복사
따라서 item 9에서 타입 선언을 지향하라 한 것이다.
요약
객체 리터럴을 변수에 할당하거나 함수에 매개변수로 전달할 때 잉여 속성 체크가 수행된다.
잉여 속성 체크는 오류를 찾는 효과적인 방법이지만, 타입스크립트 타입 체커가 수행하는 할당 가능 검사와 역할이 다르다.
할당의 개념을 정확히 알아야 잉여 속성 체크할당 가능 검사를 구분할 수 있다.
잉여 속성 체크에는 한계가 있다. temp 변수를 도입하면 잉여 속성 체크를 무시할 수 있다는 걸 기억하자.

item 12. 함수 표현식에 타입 적용하기

자바스크립트와 타입스크립트에서는 함수 문장(statement)와 함수 표현식(expression)을 다르게 인식한다.
function rollDice1(sides: number): number { ... }; // statement const rollDice2 = function(sides: number): number { ... }; // expression const rollDice3 = (sides: number): number => { ... }; // expression
TypeScript
복사
가급적이면 함수 표현식을 사용하는 것이 좋다.
함수의 매개변수부터 변환값까지 전체를 함수 타입으로 선언하여 함수 표현식에 재사용할 수 있기 때문이다.
함수 타입의 선언은 불필요한 코드의 반복을 줄인다.
function add(a: number, b: number) { return a + b }; function sub(a: number, b: number) { return a - b }; function mul(a: number, b: number) { return a * b }; function div(a: number, b: number) { return a / b };
TypeScript
복사
// 함수 타입 선언 type BinaryFn = (a: number, b: number) => number; const add: BinaryFn = (a, b) => a + b; const sub: BinaryFn = (a, b) => a - b; const mul: BinaryFn = (a, b) => a * b; const div: BinaryFn = (a, b) => a / b;
TypeScript
복사
이처럼 반복되는 함수 시그니처를 하나의 함수 타입으로 통합할 수 있다.
공통 함수 시그니처를 사용하는 것은 일종의 추상화 계층을 제공해준다. 불필요한 메소드 시그니처의 반복을 막아주고 메소드 시그니처의 가독성을 높여준다.
const add: BinaryFn = (a, b) ⇒ a + b;
이 함수를 사용하는 메소드에서 필요한 정보는 뭘 가지고 뭘 반환해주는 정도만 알면 된다.
a가 필요하고 b가 필요하다는 구현 세부 사항은 외부에서 알 필요가 없으므로 공통 함수 시그니처를 사용하면 어떻게 되겠구나의 흐름정도는 파악하기 쉬우므로 더 나은 것 같다.
함수의 매개변수에 타입 선언을 하는 것보다 함수 표현식 전체 타입을 정의해두는 것이 코드도 간결하고 더 안전하다.
다른 함수의 시그니처와 동일한 타입을 가지는 함수를 작성하거나 동일한 타입 시그니처를 가지는 여러 함수를 정의해야할 경우, 매개변수의 타입과 반환 타입을 반복해서 작성하지 않고 함수 전체의 타입 선언을 적용하는 것이 좋다.
요약
매개변수나 반환 값에 타입을 명시하기보다는 함수 표현식 전체에 타입 구문을 적용하는 것이 좋다.
만약 같은 시그니처를 반복적으로 작성한 코드가 있다면 함수 타입을 분리해 내거나 이미 존재하는 타입을 찾아보도록 하자.
라이브러리를 직접 만든다면 공통 콜백에 타입을 제공해야만 한다.
다른 함수의 시그니처를 참조하려면 typeof {functionName}을 사용하면 된다.

item 13. 타입과 인터페이스의 차이점 알기

타입스크립트에서 타입을 정의하는 방법은 두 가지가 있다.
type TState = { name: string; capital: string; }
TypeScript
복사
interface IState = { // interface의 접두사로 I를 사용하는 것은 C#에서 비롯된 관습으로 지금은 지양한다. name: string; capital: string; }
TypeScript
복사
대부분의 경우에는 이 둘을 혼용해서 사용해도 괜찮으나 둘의 차이를 명확하게 알고 같은 상황에선 일관적인 방법으로 타입을 사용해야 한다. 인터페이스에는 보강 기법이 존재하기 때문이다.
유사한 점
명명된 타입은 인터페이스로 정의하든 타입으로 정의하든 상태 자체에는 차이가 없다.
만약 변수에 타입 선언 후, 객체 리터럴을 할당할 때, 추가적인 속성이 존재한다면 두 방법 모두 동일한 오류가 발생한다.
두 방법 모두 인덱스 시그니처를 사용할 수 있다.
type TDict = { [key: string]: string } interface IDict { [key: string]: string }
TypeScript
복사
함수의 타입도 인터페이스나 타입으로 정의할 수 있다.
type TFn = (x: number) => string; interface IFn { (x: number): string; }
TypeScript
복사
제네릭도 사용 가능하다.
type TPair<T> = { first: T; second: T; } interface IPair<T> { first: T; second: T; }
TypeScript
복사
추가적인 속성의 경우도 어느것을 선택해도 별 차이가 없다.
type TFnWithProperties = { (x: number): number; props: string; } interface IFnWithProperties { (x: number): number; props: string; }
TypeScript
복사
인터페이스와 타입은 서로 확장 가능하다.
interface IStateWithPop extends TState { population: number; } type TStateWithPop = IState & { population: number; };
TypeScript
복사
IStateWithPopTStateWithPop은 동일하다. 다만 주의할 점은 인터페이스의 경우 유니온 타입과 같은 복잡한 타입은 확장하지 못한다는 것이다.
복잡한 타입을 확장하고 싶다면 타입으로 선언하고 &을 사용해야 한다.
구상 클래스를 작성할 때는 타입과 인터페이스 둘 다 사용할 수 있다.
class StateT implements TState { name: string = ''; capital: string = ''; }
TypeScript
복사
class StateI implements IState { name: string = ''; capital: string = ''; }
TypeScript
복사
차이점
인터페이스엔 있으나 타입에는 없는 부분을 알아보자.
속성을 추가할 때 보강 기법을 사용할 수 있다.
interface IState { name: string; capital: string; } interface IState { popluation: number; } const wyoming: IState = { name: 'Wyoming', capital: 'Cheyenne', popluation: 500_000 }; // 정상
TypeScript
복사
이 예제처럼 속성을 확장하는 것을 선언 병합이라고 한다.
결론
복잡한 타입이라면 고민할 것도 없이 타입을 사용하면 된다.
그러나 타입과 인터페이스, 두 가지 방법으로 모두 표현할 수 있는 간단한 객체 타입이라면 일관성과 보강의 관점에서 고려해 봐야 한다.
타입을 정의할 때 어떤 방법을 쓰고 있었는지 일관성있게 사용하면 된다.
아직 스타일이 확실하게 규정되지 않은 프로젝트라면 향후에 변동 가능성이 높은지 확인해야 봐야한다.
이런 경우 보강 기법을 사용할 수 있는 인터페이스를 사용하는 것이 좋은 선택일 수 있다.
요약
타입과 인터페이스의 차이점과 유사한 점을 이해하고 사용해야 한다.
한 타입을 typeinterface 두 가지 문법을 사용해서 작성할 줄 알아야 한다.
프로젝트에서 어떤 문법을 사용할지 고려할 때 한 가지 일관된 스타일을 따르는 것이 좋으며 이때 보강 기법이 필요한지 고려해야 한다.

item 14. 타입 연산과 제네릭 사용으로 반복 줄이기

코드의 중복을 줄이고 DRY 원칙을 지키기 위해 재사용성이 높은 코드를 메소드로 추출해낸 경험이 존재할 것이다.
타입의 중복을 줄이기 위해서 우린 타입 연산과 제네릭을 사용할 수 있다.
타입 중복이 코드 중복에 비해 흔한 이유는 공유 속성들을 제거하는 매커니즘이 기존 코드 중복을 줄이는 매커니즘과 달라 익숙하지 않기 때문이다.
타입 간 매핑하는 방법을 익히면 타입 정의에서도 DRY의 장점을 적용할 수 있다.
반복을 줄이는 가장 간단한 방법은 타입에 이름을 붙이는 것이다.
function distance(a: {x: number, y: number}, b: {x: number, y: number}) { ... }
TypeScript
복사
interface Point { x: number; y: number; } function distance(a: Point, b: Point) { ... }
TypeScript
복사
이는 코드에서 상수를 이용해서 반복을 줄이는 기법을 동일하게 타입 시스템에 적용한 것이다.
이미 존재하는 타입을 확장하는 경우에는 일반적이지는 않으나 인터섹션 연산자&를 사용할 수도 있다.
type PersonWithBirthDate = Person & { birth: Date };
이 기법은 유니온 타입에 속성을 추가해야할 때 특히 유용하다.
| 얘 말이다.
인덱싱을 사용한 기법도 있다.
interface State { userId: string; pageTitle: string; recentFiles: string[]; pageContents: string; }
TypeScript
복사
interface TopNavState { userId: string; pageTitle: string; recentFiles: string[]; }
TypeScript
복사
위 예시를 다음과 같이 변경할 수 있다.
type TopNavState { userId: State['userId']; pageTitle: State['pageTitle']; recentFiles: State['recentFiles']; }
TypeScript
복사
이는 매핑된 타입을 사용하면 조금 더 개선된 코드를 얻을 수 있다.
type TopNavState { [k in 'userId' | 'pageTitle' | 'recentFiles']: State[k] }
TypeScript
복사
매핑된 타입은 배열의 필드를 순회하는 것과 같은 방식으로 이 패턴은 표준 라이브러리에서도 일반적으로 찾아볼 수 있으며 Pick이라 한다.
type Pick<T, K> = { [k in K]: T[k] };
TypeScript
복사
다음과 같이 사용할 수 있다.
type TopNavState = Pick<State, 'userId' | 'pageTitle' | 'recentFiles'>;
TypeScript
복사
태그된 유니온에서도 타입의 중복이 발생할 수 있다.
다음과 같이 단순히 태그를 붙이기 위해서 타입을 사용한다면 어떻게 될까?
interface SaveAction { type: 'save'; ... } interface LoadAction { type: 'load'; ... } type Action = SaveAction | LoadAction; type ActionType = 'save' | 'load';
TypeScript
복사
이를 인덱싱하면 다음과 같아질것이다.
interface SaveAction { type: ActionType['save']; ... } interface LoadAction { type: ActionType['load']; ... } type Action = SaveAction | LoadAction; type ActionType = 'save' | 'load';
TypeScript
복사
다음과 같이 Pick을 사용할 수 있다.
type ActionRec = Pick<Action, 'type'>;
TypeScript
복사
객체가 생성된 후, 업데이트가 가능한 클래스를 정의한다면 update 메소드 매개변수의 타입은 생성자와 동일한 매개변수이면서 타입 대부분이 선택적인 속성값이 된다.
interface User { userId: number; username: string; }
TypeScript
복사
interface UserUpdate { userId?: number; username?: string; }
TypeScript
복사
이처럼 중복적으로 선언할 필요 없이, 매핑된 타입과 keyof를 사용해서 User로부터 UserUpdate를 만들수 있다.
type UserUpdate = {[k in keyof User]?: User[k]};
TypeScript
복사
이 패턴 역시 아주 일반적이며 표준 라이브러리에 Partial이라는 이름으로 포함되어 있다.
값의 형태에 해당하는 타입을 정의하고 싶을 때도 있다.
const INIT_OPTIONS = { width: 640, height: 480, color: '#00FF00', label: 'VGA', }; interface Option { width: number, height: number, color: string, label: string, };
TypeScript
복사
이런 경우 typeof를 사용하면 된다.
type Options = typeof INIT_OPTIONS;
Visual Basic
복사
이 코드는 자바스크립트의 런타임 연산자 typeof를 사용한 것처럼 보이지만 실제로는 타입스크립트 단계에서 연산되며 훨씬 더 정확하게 타입을 표현한다.
그러나 값으로부터 타입을 만들어내야하는 경우, 선언의 순서에 주의해야 한다. 타입 정의를 먼저하고 값이 그 타입에 할당 가능하다고 선언하는 것이 좋다.
함수나 메소드의 반환 값에 타입을 만들고 싶을 수도 있다.
function getUserInfo(userId: string) { ... return { userId, name, age, height, wieght, favoriteColor, }; } // 추론된 반환 타입은 { userId: string, name: string, age:number ... }와 같다.
TypeScript
복사
이때는 조건부 타입이 필요한데, 표준 라이브러리에는 이러한 일반적 패턴의 제네릭 타입이 이미 정의되어있다. ReturnType 제네릭이다.
type UserInfo = ReturnType<typeof getUserInfo>;
제네릭 타입은 코드에서 중복된 부분의 메소드 추출과 유사하다. 따라서 타입에 있어서 DRY 원칙의 핵심은 제네릭이다.
함수에서 매개변수로 사용할 수 있는 값을 제한하기 위해서 타입 시스템을 사용하는 것처럼 제네릭 타입에서도 타입을 제한할 수 있는 방법이 필요하다.
일반적으로 제네릭 타입에서 매개변수를 제한할 수 있는 방법은 extends를 사용하는 것인데, extends를 사용하면 제네릭 매개변수가 특정 타입을 확장한다고 선언할 수 있다.
interface Name { first: string; last: string; } type DancingDuo<T extends Name> = [T, T]; const couple1: DancingDuo<Name> = [ { first: 'Fred', last: 'Astaire'}, { first: 'Ginger', last: 'Rogers'}, ] // 정상 const couple2: DancingDuo<{first: stirng}> = [ { first: 'Fred' }, { first: 'Ginger' }, ] // 오류가 발생한다. 제네릭 T는 Name 타입을 확장해야 한다.
TypeScript
복사
값 공간에서와 마찬가지로 반복적인 코드는 타입 공간에서도 좋지 않다.
타입 공간에서 반복을 줄이려는 작업들은 익숙하지는 않지만 배워둘만한 가치가 있다. 반복하지 않도록 주의해야 한다.
요약
DRY 원칙을 타입에도 최대한 도입해야 한다.
타입에 이름을 붙여서 반복을 피하라. extends를 사용해서 인터페이스 속성의 반복을 줄여야 한다.
타입들 간의 매핑을 위해 타입스크립트가 제공한 도구를 공부하면 좋다.
여기에는 keyof, typeof, 인덱싱, 매핑된 타입들이 있다.
제네릭 타입은 타입을 위한 함수와 같다. 타입을 반복하는 대신 제네릭 타입을 사용하여 타입들 간에 매핑을 하는 것이 좋다.
제네릭 타입을 특정 타입을 확장하는 식으로 제한하고 싶다면 extends를 사용하면 된다.
표준 라이브러리에 정의된 Pick, Partial, ReturnType과 같은 제네릭 타입을 자주 사용해보자.

item 15. 동적 데이터에 인덱스 시그니처 사용하기

타입스크립트에서는 타입에 ‘인덱스 시그니처’를 명시하여 유연하게 매핑을 표현할 수 있다.
type Rocket = {[property: string]: string}; const rocket: Rocket = { name: 'Falcon 9', variant: 'v1.0', thrust: '4,940 kN', }; // 정상
TypeScript
복사
[property: string]: string이 인덱스 시그니처이며 다음 세 의미를 담고 있다.
키의 이름: 키의 위치만을 표시하는 용도다. 타입 체커에서는 사용되지 않으므로 무시할 수 있는 참고 정보정도다.
키의 타입: string이나 number또는 symbol의 일종이어야 하지만 보통은 string을 사용한다.
값의 타입: 여기선 string으로 되어있으나 어느것이든 될 수 있다.
이런식으로 타입 체크를 수행하게 되면 다음과 같은 문제들이 발생할 수 있다.
잘못된 이름의 키를 포함해 모든 키를 허용한다. name 대신 Name을 작성해도 유효한 타입이 된다.
특정 키가 필수적으로 요구되지 않는다. {} 타입도 유효한 Rocket 타입이 된다.
키마다 다른 타입을 가질 수 없다. 예를 들어 thruststring이 아닌 다른 타입을 가지지 못한다.
타입스크립트 언어 서비스는 각 속성 값이 존재하지 않는 것으로 알고 있기 때문에 자동완성을 지원해주지 않는다.
가급적이면 인터페이스나 타입을 이용해 정확하게 타입을 규정하는 것이 좋으나 동적 데이터를 타이핑해야하는 경우에 인덱스 시그니처를 사용하는 것이 좋다.
CSV 파일처럼 헤더 행에 열 이름이 존재하고 데이터 행을 열 이름과 값으로 매핑하는 객체로 나타내고 싶은 경우가 대표적이다.
일반적인 상황에서 CSV 파일의 열 이름이 무엇인지 알고 있을 방법은 없다.
알고 있다면 이는 해당 메소드가 파일에 과의존하고 있다는 증거다.
연관 배열의 경우, 객체에 인덱스 시그니처를 사용하는 대신 Map 타입을 사용하는 것을 고려할 수 있다.
이는 프로토타입 체인과 관련된 유명한 문제를 우회하는데, 구체적인 예시는 Item 58에 존재한다.
동적 데이터를 다루기 위해 타입에 유연성을 제공할 수 있는 방법은 다양하다.
만약 string 타입이 특정 속성의 키를 표현하기에 너무 광범위해서 인덱스 시그니처를 사용하는 데 문제가 있다면 다음 두 대안을 생각해볼 수 있다.
첫 번째, Record를 사용하는 방법이다.
string 타입의 부분 집합을 사용하는 방법이다.
type Vec3D = Record<'x' | 'y' | 'z', number>; Type Vec3D = { x: number; y: number; z: number; }
TypeScript
복사
두 번째, 매핑된 타입을 사용하는 방법이다.
type Vec3D = {[k in 'x' | 'y' | 'z']: number}; type ABC = {[k in 'a' | 'b' | 'c']: k extends 'b' ? string : number};
TypeScript
복사
요약
런타임 때까지 객체의 속성을 알 수 없는 경우에만 인덱스 시그니처를 사용하도록 한다.
안전한 접근을 위해 인덱스 시그니처의 값 타입에 undefined를 추가하는 것을 고려해야 한다.
가능하다면 인터페이스, Record, 매핑된 타입과 같은 인덱스 시그니처보다 정확한 타입을 사용하는 것이 좋다.

item 16. number 인덱스 시그니처보다는 Array, 튜플, ArrayLike 사용하기

자바스크립트가 이상하게 동작하는 것 중 가장 악명이 높은 것은 암시적 타입 변환과 관련된 부분이다.
다행히도 암시적 타입 강제와 관련된 문제는 대부분 ===!==를 이용해서 해결할 수 있다.
자바스크립트 객체 모델에도 이상한 부분들이 존재하며 이 중 일부는 타입스크립트 타입 시스템으로 모델링되기 때문에 이에 대한 이해가 필요하다.
자바스크립트에서 객체란 키/값 쌍의 모음이다.
키는 보통 문자열로 심볼일 수 있으며 값은 어떤 것이든 될 수 있다.
파이썬이나 자바에서 볼 수 있는 해시 가능 객체라는 표현이 자바스크립트에는 존재하지 않는다. 만약 복잡한 객체를 키로 사용하려고 하면 toString 메소드가 호출되어 객체가 문자열로 변환된다.
특히 숫자는 키로 사용할 수 없다. 만약 속성 이름으로 숫자를 사용하려고 하면, 자바스크립트 런타임은 문자열로 변환한다.
배열은 분명히 객체다. 그렇다면 속성의 키로 숫자를 사용한다면 자바스크립트 런타임은 이를 문자열로 변환할 것이다.
배열의 인덱스는 문자열로 변환되어 사용된다. 문자열 키를 이용해도 배열의 요소에 접근이 가능하다.
x = [1, 2, 3] x[1] // 2 x['1'] // 2
TypeScript
복사
Object.keys(x)를 호출하면 키가 문자열로 출력된다.
타입 스크립트는 이러한 혼란을 바로잡기 위해 숫자 키를 허용하고 문자열 키와 다른 것으로 인식한다.
interface Array<T> { ... [n: number]: T; }
TypeScript
복사
런타임 상에는 ECMAScript 표준에 서술된 것처럼 문자열 키로 인식하므로 가상의 코드라고 할 수 있지만 타입 체크 시점에 오류를 잡을 수 있어 유용하다.
따라서 다음 코드는 컴파일되지 않는다.
x = [1, 2, 3] x[1] // 2 x['1'] // ~~~ 인덱스 식이 number 형식이 아니므로 요소에 암시적으로 any 형식이 있습니다.
TypeScript
복사
하지만 여전히 Object.keys 같은 구문은 여전히 문자열로 반환된다.
const xs = [1, 2, 3] const keys = Object.keys(xs); for (const key in keys) { console.log(key); console.log(xs[key]); // key가 문자열이므로 오류가 발생해야 하나 '실용적인 허용'으로 허용해준다고 한다. }
TypeScript
복사
배열을 순회하는 코드 스타일에 대한 실용적인 허용이라고 생각하는 것이 좋다.
자바스크립트에서는 흔한 일이지만 이 예제가 배열을 순회하기 좋은 방법은 아니다.
인덱스에 신경 쓰지 않는다면 for-of를 사용하는 게 더 좋다.
for (const x of xs) {}
TypeScript
복사
인덱스의 타입이 중요하다면 number 타입을 제공해주는 Array.prototype.forEach를 사용하면 된다.
xs.forEach((x, i) => { i, x; }
TypeScript
복사
루프 중간에 멈춰야 한다면 C 스타일인 for(;;) 루프를 사용하는 것이 좋다.
for (let i = 0; i < xs.length; i++) { const x = xs[i]; if (x < 0) break; }
TypeScript
복사
타입이 불확실하다면 대부분의 브라우저와 자바스크립트 엔진에서 for-in 루프는 for-of 또는 C 스타일 for 루프에 비해 몇 배나 느리다.
인덱스 시그니처가 number로 표현되어 있다면 입력한 값이 number여야 한다는 것을 의미하지만 실제 런타임에 사용되는 키는 string 타입이다.
일반적으로 string 대신 number를 타입의 인덱스 시그니처로 사용하는 일은 많지않다. 속성 값을 숫자로 사용하진 않으니까..
만약 숫자를 사용하여 인덱스할 항목을 지정한다면 Array 또는 튜플 타입을 대신 사용한다는 의미다.
어떤 길이를 가지는 배열과 비슷한 형태의 자료구조를 사용하고 싶다면 타입스크립트에 있는 ArrayLike 타입을 사용하라.
function checkedAccess<T>(xs: ArrayLike<T>, i: number): T { if (i < xs.length) { return xs[i]; } throw new OutOfRangeError(); }
TypeScript
복사
요약
배열은 객체로 키는 숫자가 아니라 문자열이다.
인덱스 시그니처로 사용된 number 타입은 버그를 잡기 위한 단순 타입스크립트 코드다.
인덱스 시그니처에 number를 사용하기보단 Array나 튜플, 또는 ArrayLike 타입을 사용하는 것이 좋다.

item 17. 변경 관련된 오류 방지를 위해 readonly 사용하기

매개변수를 readonly로 선언하면 다음과 같은 작업을 수행한다.
타입스크립트는 매개변수가 함수 내에서 변경이 발생하는지 확인한다.
호출하는 쪽에서는 함수가 매개변수를 변경하지 않는다는 보장을 받게 된다.
호출하는 쪽에서 함수가 readonly 배열을 매개변수로 넣을 수 있다.
자바스크립트는 명시적으로 언급하지 않는 한 함수가 매개변수를 변경하지 않는다고 가정한다. 그러나 이러한 암묵적인 방법은 타입 체크에 문제를 일으킬 수 있다.
readonly는 얕게 동작한다는 것에 유의하며 사용해야 한다. 만약 객체의 readonly 배열이 존재한다면 그 객체 자체는 readonly는 아니다.
interface Outer { inner: { x: number; } } type T = Readonly<Outer>;
TypeScript
복사
중요한 점은 readonly 접근제어자는 inner에 적용되는 것이지 x는 아니라는 것이다.
현 시점에서 깊은 readonly 타입이 기본적으로 지원이 되지 않으므로 만약 사용을 원한다면 ts-essentialsDeepReadonly<T>와 같은 서드파티를 사용하면 된다.
인덱스 시그니처에 readonly를 사용하면 객체의 속성이 변경되는 것을 방지할 수 있다.
let obj: {readonly [k: string]: number} = {};
TypeScript
복사
요약
만약 함수가 매개변수를 수정하지 않는다면 readonly로 선언하는 것이 좋다.
readonly 매개변수는 인터페이스를 명확하게 하며 매개변수가 변경되는 것을 방지한다.
readonly를 사용하면 변경하면서 발생하는 오류를 방지할 수 있고 변경이 발생하는 코드도 쉽게 찾을 수 있다.
constreadonly의 차이를 이해해야 한다.
readonly는 얕게 동작한다는 것을 명심해야 한다.

item 18. 매핑된 타입을 사용하여 값을 동기화하기

type ScatterProps = { // Data xs: number[]; ys: number[]; // Display xRange: [number, number]; yRange: [number, number]; color: string; // Events onClick: (x: number, y: number, index: number) => void; };
TypeScript
복사
위와 같은 코드에서 불필요한 작업을 피하기 위해 값이 변경될 때만 차트를 그리게 하고 싶다면 다음과 같이 최적화해볼 수 있다.
실패에 닫힌, 보수적 접근법
const shouldUpdate = (oldProps: ScatterProps, newProps: ScatterProps) => { let key: keyof ScatterProps; for (key in oldProps) { if (oldProps[key] !== newProps[key]) { if (key !== "onClick") return true; } } return false; };
TypeScript
복사
실패에 열린 접근법
const shouldUpdate = (oldProps: ScatterProps, newProps: ScatterProps) => { return ( oldProps.xs !== newProps.xs || oldProps.ys !== newProps.ys || oldProps.xRange !== newProps.xRange || oldProps.yRange !== newProps.yRange || oldProps.color !== newProps.color ); };
TypeScript
복사
이상적인 해결법
const REQUIRES_UPDATE: { [key in keyof ScatterProps]: boolean } = { xs: true, ys: true, xRange: true, yRange: true, color: true, onClick: false, }; const shouldUpdate = (oldProps: ScatterProps, newProps: ScatterProps) => { let key: keyof ScatterProps; for (key in oldProps) { if (oldProps[key] !== newProps[key] && REQUIRES_UPDATE[key]) { if (key !== "onClick") return true; } } return false; };
TypeScript
복사
타입 체커가 동작하도록 개선되었으며 핵심은 매핑된 타입과 객체를 사용하는 것이다.
요약
매핑된 타입을 사용해서 관련된 값과 타입을 동기화하도록 하자.
인터페이스에 새로운 속성을 추가할 때 선택을 강제하도록 매핑된 타입을 고려해야 한다.