이펙티브 타입스크립트 - 02 (2)
📋 Item 13. 타입과 인터페이스의 차이점 알기
타입스크립트에서 타입을 정의 하는 방법을 아래 두 가지가 있다.
type State = {
name: string
capital: string
}
interface State {
name: string
capital: string
}
타입을 정의할 때 인터페이스 대신 클래스를 사용할 수도 있지만, 클래스는 값으로도 사용될 수 있는 자바스크립트 런타임 개념이다.
대부분의 경우에는 타입을 사용해도 되고 인터페이스를 사용해도 된다.
그러나, 타입과 인터페이스 사이에 존재하는 차이를 분명하게 알고, 같은 상황에서는 동일한 방법으로 타입을 정의해 일관성을 유지해 주는게 좋다.
그러려면 하나의 타입에 대해 두 가지 방법 모두 사용해서 정의 할 줄 알아야 한다.
먼저, 타입과 인터페이스로 정의 할 때 비슷한 점을 알아보자.
// type, interface로 정의 한다면 동일한 오류를 확인할 수 있다.
// Error: State형식에 population가 존재하지 않는다.
const wyoming: State = {
name: 'Wyoming',
capital: 'Cheyenne',
population: 500_000
}
// 인덱스 시그니처는 인터페이스와 타입에서 모두 사용할 수 있다.
type Dict = { [key: string]: string };
interface Dict { [key: string]: string }
// 함수 타입도 인터페이스와 타입에서 모두 정의 할 수 있다.
type Fn = (x: number) => string;
interface Fn {
(x: number) => string;
}
// 제네릭을 인터페이스와 타입에서 모두 사용할 수 있다.
type Pair<T> = {
first: T;
second: T;
}
interface Pair<T> {
first: T;
second: T;
}
// 타입확장도 가능하다.(❗️인터페이스는 유니온타입 같은 복잡한 타입을 확장하지 못한다.)
interface StateWithPop extends State {
population: number;
}
type StateWithPop = State & { population: number; }
타입과 인터페이스의 다른점을 알아보자!!
인터페이스는 유니온 타입같은 복잡한 타입을 확장하지 못한다. 복잡한 타입을 확장하고 싶으면 타입과 &를 사용해야 한다.
type AorB = 'a' | 'b'
인터페이스는 타입을 확장할 수 있지만 위와같은 유니온은 할 수 없다.
또 튜플과 배열타입도 type키워드를 사용하면 더 간결하게 표현할 수 있다.
type Tuple = [number, number]
interface Tuple {
0: number
1: number
length: 2
}
하지만, 인터페이스로 튜플과 비슷하게 구현하면 튜플에서 사용할 수 있는 concat
같은 메서드를 사용할 수 없다.
반면, 인터페이스는 타입에 없는 몇가지 기능이 존재한다. 보강(augment)
이 가능하다는 것이다.
첫번째 등장했던 State
인터페이스에 population
필드를 보강기법으로 사용해 추가해보자.
interface State {
name: string
capital: string
}
interface State {
population: number
}
// OK
const wyoming: State = {
name: 'Wyoming',
capital: 'Cheyenne',
pouplation: 500_000,
}
이 예제처럼 속성을 확장하는 것을 선언병합(declaration merging)
이라고 한다.
병합은 선언처럼 언제든지 가능하기 때문에 프로퍼티가 추가되는 것을 원하지 않는다면 인터페이스 대신 타입을 사용하는 것이 좋다.
그럼 프로젝트에는 타입과 인터페이스 어느것을 사용해야 할까??
복잡한 타입을 사용한다면 타입을 사용해야 한다.. 하지만 간단한 객체 타입이라면 일관성과 보강의 관점에서 고려해봐야 한다.
인터페이스를 사용하는 코드베이스에서 작업하고있다면 인터페이스를 사용하고 타입을 사용중이라면 타입을 사용하자.
아직 스타일이 정해지지 않은 프로젝트라면 향후 보강의 가능성이 있을지도 생각해봐야한다.
📕 요약
-
타입과 인터페이스의 차이점과 비슷한 점을 이해하자
-
하나의 타입을 type과 interface 두 가지 문법을 사용해서 작성하는 방법을 알아두자
-
프로젝트에서 어떤 문법을 사용할지 결정할 때 한가지 일관된 스타일을 확립하고, 보강기법이 필요한지 고려해야 한다.
📋 Item 14. 타입 연산과 제너릭 사용으로 반복 줄이기
코드를 작성할 때 같은 코드를 반복하는 것은 좋지 않은 방법이라는 것은 개발자라면 알고 있을 것이다.
이런 DRY원칙을 지켜왔던 개발자라도 타입에 대해서는 간과했을지도 모른다.
아래 예제를 살펴보자.
interface Person {
firstName: string
lastName: string
}
interface PersonWithBirthDate {
firstName: string
lastName: string
birth: Date
}
위의 타입에서 선택적 필드인 middleName을 Person
에 추가한다고 가정해보자.
그러면 Person
과 PersonWithBirthDate
는 다른 타입이 된다.
이러한 문제는 하나의 인터페이스가 다른 인터페이스를 확장하게 해서 반복을 제거 할 수 있다.
interface Person {
firstName: string
lastName: string
}
interface PersonWithBirthDate extends Person {
birth: Date
}
이렇게 확장하게 되면 추가적인 필드만 작성해주면 된다. 두 인터페이스가 부분집합을 공유한다면 공통 필드만 골라서 기반 클래스로 분리해 낼 수 있을 것이다.
이제 다른 방법도 생각해보자
interface State {
userId: string
pageTitle: string
recentFiles: string[]
pageContents: string
}
interface TopNavState {
userId: string
pageTitle: string
recentFiles: string[]
}
이렇게 정의 되어 있을경우 TopNavState
을 확장해서 State
를 구성하기 보다 인덱싱하여 속성의 타입에서 중복을 제거 할 수도 있다.
type TopNavState = {
userId: State['userId']
pageTitle: State['pageTitle']
recentFiles: State['recentFiles']
}
이렇게 작성하면 State
의 pageTitle
의 타입이 바뀌면 TopNavState
에도 반영되지만, 아직 반복되는 코드가 존재한다.
이럴때 아래와 같이 매핑된 타입을 사용하면 좀 더 나아진다.
type TopNavState = {
[k in 'userId', 'pageTitle', 'recentFiles'] = State[k]
}
위의 코드는 유틸리팁 타입을 사용해서 아래와 같이 사용할 수도 있다.
type TopNavState = Pick<State, 'userId', 'pageTitle', 'recentFiles'>
📕 요약
-
DRY원칙을 타입에도 최대한 적용해야 한다.
-
타입에 이름을 붙여서 반복을 피하자! extends를 사용해서 인터페이스 필드의 반복을 피하자!
-
타입들 간의 매핑을 위해 타입스크립트가 제공한 도구 keyof, typeof, 인덱싱, 매핑된 타입을 알아두자
-
타입을 반복하는 대신 제너릭 타입을 사용하여 타입들 간에 매핑하는것이 좋다.
-
표준 라이브러리에 정의된 Pick, Partial, ReturnType같은 유틸리티 타입에 익숙해지자.
📋 Item 15. 동적 데이터에 인덱스 시그니처 사용하기
자바스크립트의 장점 중 하나는 객체를 생성하는 문법이 간단하다는 것이다.
타입스크립트에서는 타입에 인덱스 시그니처
를 명시하여 유연하게 매핑을 표현할 수 있다.
type Rocket = { [property: string]: string }
const rocket: Rocket = {
name: 'Falcon 9',
variant: 'v1.0',
thrust: '4,940kN',
}
여기서 [property: string]: string
이 인덱스 시그니처이며 다음 의미를 담고 있다.
-
키의 이름: 키의 위치만 표시하는 용도이며 타입체커에서는 사용하지 않는다
-
키의 타입: string이나 number, symbol의 조합이여야 하지만, 보통은 string을 사용한다.
-
값의 타입: 어떤 타입이든 사용가능하다.
하지만 이렇게 타입체크가 수행될 경우 아래 4가지 단점이 드러나게 된다..
-
잘못된 키를 포함에 모든 키를 허용한다.. name대신 Name으로 작성해도 유효한 Rocket타입이 된다.
-
특정 키가 필요하지 않는다. 도 유효한 Rocket타입이다.
-
키마다 다른 타입을 가질 수 없다.
-
name:을 입력할 때, 키는 무엇이든 가능하지 때문에 자동완성 기능이 동작하지 않는다.
이렇기 때문에 인덱스 시그니처는 부정확하므로 더 나은 방법을 찾아야한다.
위와 같은 경우에는 인터페이스로 작성하고 모든 필수 필드가 존재하는지 확인하게 하는 방법이 좋을 것 같다.
interface Rocket {
name: string
variant: string
thrust_KN: number
}
const rocket: Rocket = {
name: 'Falcon 9',
variant: 'v1.0',
thrust: 4_900,
}
인덱스 시그니처는 동적 데이터를 표현할 때 사용한다.
예를들어 CSV파일처럼 행에 열이름이 존재하고 그 아래 해당하는 값이 존재하고 이러한 데이터를 이름과 값으로 매핑하는 객체로 나타내고 싶을 때 사용한다.
일반적인 상황에서 열이름이 무엇인지 미리 알 방법이 없기 떄문에 인덱스 시그니처를 사용한다.
반면, 열 이름을 미리 알고 있는 경우에는 미리 선언해 둔 타입으로 단언문을 사용하는게 바람직하다.
📕 요약
-
런타임 때까지 객체의 속성을 알 수 없을 경우에만 인덱스 시그니처를 사용하자!!
-
가능하면 인터페이스, Record, 매핑된 타입 같은 인덱스 시그니처보다 정확한 타입을 사용하는 것이 좋다.
📋 Item 16. Array, 튜플, ArrayLike사용하기
number인덱스 시그니처보다 Array, 튜플, ArrayLike를 사용하자!
자바스크립트는 이상하게 동작하기로 유명한 언어이다. 그중 가장 악명높은 것은 암시적 타입 강제와 관련된 부분인데
"0" == 0
은 true
를 반환한다. 그렇지만 이러한 문제는 대부분 ===
나 !==
를 사용해서 해결할 수 있다.
자바스크립트에서 객체란 키/값 쌍의 모음이다. 키는 보통 문자열이나 심벌이며 값은 어떠한 값이든 들어갈 수 있다.
아래 예시코드를 살펴보자
// 배열은 객체이다.
> typeof []
'object'
// 숫자인덱스를 사용해서 배열에 접근할 수 있다.
> x = [1, 2, 3]
[1, 2, 3]
> x[0]
1
// 문자열 키를 사용해도 접근할 수 있네??
> x['1']
2
// 배열의 키를 나열해보면 키가 문자열로 출력됨
> Object.keys(x)
['0', '1', '2']
위와같이 숫자는 키로 사용할 수 없다. 만약 속성 이름으로 숫자를 사용하려고 하면 자바스크립트 런타임은 문자열로 변환할 것이다.
타입스크립트는 이런 혼란을 바로잡기 위해 숫자 키를 허용하고, 문자열 키와 다른것으로 인식하기 때문에 타입체크 시점에서 오류를 잡을 수 있다.
인덱스 시그니처가 number로 표현되어 있다면 입력한 값이 number여야 한다는 것을 의미하지만, 실제 런타임에 사용되는 키는 string타입을 사용한다.
혼란스럽게 느껴질 수도 있지만 일반적으로 string대신 number타입을 인덱스 시그니처로 사용할 이유는 많지 않다.
숫자를 사용하여 인덱스 할 항목을 지정한다면 Array 또는 튜플 타입을 대신 사용하자!!
📕 요약
-
배열은 객체이므로 키는 숫자가 아니라 문자열이다. 인덱스 시그니처로 사용된 number 타입은 버그를 잡기위한 순수 타입스크립트 코드이다.
-
인덱스 시그니처에 number를 사용하기 보다 Array나 튜플, ArrayLike타입을 사용하는게 좋다.
📋 Item 17. 변경 관련된 오류 방지를 위해 readonly사용하기
먼저 삼각수를 출력하는 코드로 살펴보자.
function arraySum(arr: number[]) {
let sum = 0, num;
while((num = arr.pop()) !== undefined) {
sum += num;
}
return sum;
}
function printTriangles(n: number) {
const nums = [];
for (let i = 0; i < n; i++) {
nums.push(i);
console.log(arraySum(nums));
}
}
> printTriangles(5)
0
1
2
3
4
위의 코드를 실행해보면 문자가 발생할 것이다.
자바스크립트 배열은 내용을 변경할 수 있기 때문에, 타입스크립트에서도 역시 오류 없이 통과하게 된다.
오류의 범위를 좁히기 위해 arraySum이 배열을 변경하지 않는다는 readOnly선언을 하게되면 아래와 같은 에러를 확인할 수 있다.
function arraySum(arr: readonly number[]) {
let sum = 0,
num
while ((num = arr.pop()) !== undefined) {
// ERROR: readonly number[]형식에 pop속성이 없다.
sum += num
}
return sum
}
이처럼 readonly를 사용하면 배열을 변경하는 것은 허용하지 않는다.
자바스크립트(타입스크립트)에서는 명시적으로 언급하지 않는 한, 함수가 매개변수를 변경하지 않는다고 가정한다.
그러나 이러한 암묵적인 방법은 타입체크에 문제를 일으킬 수 있기 떄문에, 명시적인 방법을 사용하는 것이 좋은방법이다.
앞의 예제에서 arraySum을 고치는 방법은 간단하다. 배열을 변경하지 않으면 된다.
// 함수가 매개변수를 변경하지 않는다면 readonly를 명시적으로 사용하자
function arraySum(arr: readonly number[]) {
let sum = 0
for (const num of arr) {
sum += num
}
return sum
}
📕 요약
-
만약 함수가 매개변수를 수정하지 않는다면
readonly
로 선언하는 것이 좋다. -
readonly
를 사용하면 변경하면서 발생하는 오류를 방지할 수 있고, 변경이 발생하는 코드를 쉽게 찾을 수 있다. -
const
와readonly
의 차이를 이해하자. -
readonly
는 얕게 동작한다는 것을 명심하자.
📋 Item 18. 매핑된 타입을 사용하여 값을 동기화하기
산점도를 그리기 위한 UI컴포넌트를 작성한다고 가정해보자.
여기에는 디스플레이와 동작을 제어하기 위한 몇 가지 다른 타입의 속성이 포함된다.
interface ScatterProps {
// The data
xs: number[]
ys: number[]
// Display
xRange: [number, number]
yRange: [number, number]
color: string
// Event
onClick: (x: number, y: number, index: number) => void
}
불필요한 작업을 피하기 위해 필요할 때에만 차트를 다시 그릴 수 있다.
데이터나 디스플레이 속성이 변경되면 다시 그려야 하지만, 이벤트 핸들러가 변경되면 다시 그릴 필요가 없다.
아래에서 최적화를 시켜보자.
보수적(conservative) 접근법
,실패에 닫힌(fail close) 접근법
으로 최적화를 시켜보자.
function shouldUpdate(oldProps: ScatterProps, newProps: ScatterProps) {
let k: keyof ScatterProps
for (k in oldProps) {
if (oldProps[k] !== newProps[k]) {
if (k !== 'onClick') return true
}
}
return false
}
실패에 열린 접근법
으로 최적화
function 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
// (no check for onClick)
)
}
위의 코드는 차트를 불필요하게 다시 그리는 단점을 해결했지만, 실제로 차트를 다시 그려야 할 경우에 누락되는 일이 생길 수 있기 때문에 일반적인 경우에 쓰이는 방법은 아니다.
- 매핑된 타입과 객체를 사용해서 타입체커가 동작하도록 개선한 코드로 최적화를 시켜보자!
// [k in keyof ScatterProps]은 타입 체커에게
// REQUIRES_UPDATE가 ScatterProps과 동일한 속성을 가져야 한다는 정보를 제공한다.
const REQUIRES_UPDATE: { [k in keyof ScatterProps]: boolean } = {
xs: true,
ys: true,
xRange: true,
yRange: true,
color: true,
onClick: false,
}
function shouldUpdate(oldProps: ScatterProps, newProps: ScatterProps) {
let k: keyof ScatterProps
for (k in oldProps) {
if (oldProps[k] !== newProps[k] && REQUIRES_UPDATE[k]) {
return true
}
}
return false
}
이러한 방식은 아래처럼 속성을 추가하거나 삭제 할 때 오류를 정확히 잡아낸다.
interface ScatterProps {
// ...
// 새로운 속성추가
onDoubleClick: () => void
}
// Error: onDoubleClick속성이 타입에 없다.
const REQUIRES_UPDATE: { [k in keyof ScatterProps]: boolean } = {
// ...
}
이렇게 매핑된 타입은 한 객체가 또 다른 객체와 정확히 같은 속성을 가지게 할 때 이상적이다.
이렇게 매핑된 타입을 사용해서 타입스크립트가 코드에 제약을 강제하도록 할 수 있다.
📕 요약
-
매핑된 타입을 사용해서 관련된 값과 타입을 동기화 하자!
-
인터페이스에 새로운 속성을 추가할 때, 선택을 강제하도록 매핑된 타입을 고려하자!