-
이펙티브 타입스크립트- 2장 타입시스템 (4)TypeScript 2022. 4. 2. 23:37
아이템 15. 동적 데이터에 인덱스 시그니처 사용하기
자바스크립트 객체는 문자열 키를 타입의 값에 관계없이 매핑한다.
타입스크립트에서는 타입에 인덱스 시그니처를 명시하여 유연하게 매핑을 표현할 수 있다.
type Rocket = {[property: string]: string}; const rocket: Rocket = { name: 'Falcon 9', variant: 'v1.0', thrust: '4,940 kN', }; // 정상
인덱스 시그니처는 다음 타입 문법을 의미한다.
type Rocket = {[property: string]: string}; const rocket: Rocket = { name: 'Falcon 9', variant: 'v1.0', thrust: '4,940 kN', }; // 정상
여기서 [property:string]: string이 인덱스 시그니처이며 다음 세 가지 의미를 담고 있다.
{[키의 이름: 키의 타입]: 값의 타입}
- 키의 이름: 키의 위치만 표시하는 용도. 타입 체커에서는 사용하지 않는다.
- 키의 타입: string, number, symbol 중 하나여야 하지만, 보통 string을 사용.
- 값의 타입: 어떤 것이든 될 수 있다.
이는 유연한 매핑을 표현하지만 다음 네 가지 단점을 드러낸다.
- 잘못된 키를 포함해 모든 키를 허용한다. name 대신 Name이라고 해도 유효한 Rocket 타입이 된다.
- 특정 키가 필요하지 않다. {} 역시 유효한 Rocket 타입이다.
- 키마다 다른 타입을 가질 수 없다.(ex thrust는 string이 아니라 number여야 할 수도 있다)
- 키값으로 무엇이든 가능하기 때문에, 자동완성 기능이 도와주지 않는다.
결국 인덱스 시그니처는 부정확하므로 더 나은 방법을 찾아야 한다.
하지만 인덱스 시그니처를 써야 하는 경우도 있는데 바로 동적 데이터를 표현할 때 이다.
function parseCSV(input: string): {[columnName: string]: string}[] { const lines = input.split('\n'); const [header, ...rows] = lines; const headerColumns = header.split(','); return rows.map(rowStr => { const row: {[columnName: string]: string} = {}; rowStr.split(',').forEach((cell, i) => { row[headerColumns[i]] = cell; }); return row; }); }
일반적인 상황에서 열 이름이 무엇인지 미리 알 방법이 없기에 이 때 인덱스 시그니처를 사용하면 된다.
(반면 열 이름을 알고 있는 특정한 상황에서는 미리 선언해 둔 타입으로 단언문을 사용하면 된다.)
다만 인덱스 시그니처는 너무 광범위하고 부정확하기 때문에 런타임 때까지 객체의 속성을 알 수 없는 경우에만 사용해주도록 하고,
나머지 경우에는 인터페이스, Record, 매핑된 타입 등 대안을 찾아서 사용해주는 것이 훨씬 바람직하다.
인덱스 시그니처의 대안 두 가지
1. Record
Record는 키 타입에 유연성을 제공하는 제너릭 타입이다.
type Vec3D = Record<'x' | 'y' | 'z', number>; // type Vec3D = { // x: number; // y: number; // z: number; // }
2. 매핑된 타입(Mapped Types)
type Vec3D = {[k in 'x' | 'y' | 'z']: number}; // type Vec3D = { // x: number; // y: number; // z: number; // } type ABC = {[k in 'a' | 'b' | 'c']: k extends 'b' ? string : number}; // type ABC = { // a: number; // b: string; // c: number; // }
요약
- 런타임 때까지 객체의 속성을 알 수 없는 경우에만 (ex CSV파일 로드) 인덱스 시그니처를 사용하도록 한다.
- 안전한 접근을 위해 인덱스 시그니처의 값 타입에 undefined를 추가하는 것을 고려해야한다.
- 가능하다면 인터페이스, Record, 매핑된 타입 같은 인덱스 시그니처보다 정확한 타입을 사용해야 좋다.
아이템 16. number 인덱스 시그니처보다는 Array, 튜플, ArrayLike를 사용하기
자바스크립트에서는 숫자를 키로 사용할 수 없다.
만약 숫자를 사용하려고 하면 자바스크립트 런타임은 이를 문자열로 변환한다.
{1:2, 3:4} // {'1':2, '3':4}
배열은 분명히 객체인데 숫자 인덱스를 사용하는 것이 당연하다.
반면 배열이 객체이기 때문에 문자열 키를 사용해도 배열의 요소에 접근할 수 있다.
Object.keys를 이용해 배열의 키를 나열해봐도 키가 문자열로 출력된다.
x = [1,2,3]; x[0] //1 x['1'] //2 Object.keys(x) ['0','1','2']
타입스크립트는 이런 혼란을 바로잡기 위해 숫자 키를 허용하고 문자열 키와 다른 것으로 인식한다.
물론 자바스크릅티 런타임에서는 문자열 키로 인식되기 때문에 완전히 가상의 코드이지만
타입체크 시점에 오류를 잡을 수 있다는 의의가 있다.
보충)
- 자바스크립트 엔진에서는 object의 키는 string 타입혹은 symbol 타입만 가능
- 배열의 타입은 object이므로 따서 배열에서도 number 타입의 key로 접근 불가
- 다만 자바스크립트 엔진에서 자동으로 형변환이 되기 때문에 number 타입의 키로도 접근이 가능했던 것
(즉 위의 예에서 x[0]은 내부적으로 x['0']으로 바뀜) - 따라서 엄격하게는 배열에 접근할 때도 string타입을 통해 배열 원소에 접근해야 하지만 자바스크립트에서는 범용적으로 number타입을 통해 배열 원소 접근했기 때문에 타입스크립트에서는 일관성을 위해 number 타입 키를 허용
const xs = [1,2,3]; const x[0] = xs[0]; const x1 = xs['1']; // ~~~인덱스 식이 'number'형식이 아니므로 // 요소에 암시적으로 'any'형식이 있습니다. function get<T>(array:T[], k:string):T { return array[k]; //~~인덱스 식이 'number'형식이 아니므로 // 요소에 암시적으로 'any'형식이 있습니다 }
만약 인덱스 시그니처에 키 타입을 number로 사용하면, 키에 number가 들어가야 하지만 실제 런타임에 변환되는 키는 결국 string타입이다. 이러한 부분이 혼란스럽게 느껴질 수 있기에
number 타입의 인덱스 시그니처를 사용하는 대신에 Array 또는 튜플 타입, ArrayLike를 사용할 것을 권장한다.
ArrayLike는 유사배열객체. 배열의 각종 메서드를 제외한 length 속성과 index만을 가지는 객체 타입이다.
ArrayLike를 사용하더라도 키는 여전히 문자열이다.
인덱스 시그니처를 만들 때, 키 타입이 number 타입이라면 거의 대부분의 경우에는 이미 정의된 Array 타입이나 튜플, ArrayLike 타입을 통해 사용할 수 있습니다.Number 인덱스 시그니처를 위해 새롭게 타입을 만들 일이 거의 없다는 뜻
요약
- 배열은 객체이므로 키는 숫자가 아니라 문자열이다. 인덱스 시그니처로 사용된 number 타입은 버그를 잡기 위한 순수 타입스크립트 코드이다.
- 인덱스 시그니처에 number를 사용하기 보다 Array나 튜플 또는 ArrayLike 타입을 사용하는 것이 좋다.
아이템 17. 변경 관련된 오류 방지를 위해 readonly 사용하기
function arraySum(arr: number[]) { let sum = 0, num; while((num = arr.pop()) !== undefined) { sum += num; } return sum; }
위 함수는 배열 안의 숫자들을 모두 합치지만 계산이 끝나면 원래 배열이 전부 비게된다.
자바스크립트 배열은 내용을 변경할 수 있기 때문에 타입스크립트에서도 역시 오류 없이 통과하게 된다.
이럴 때 readonly 접근 제어자를 사용해 arraySum 함수가 매개변수로 들어오는 배열을 변경하지 않는다는 선언을 해주면 된다.
function arraySum(arr: readonly number[]) { let sum = 0, num; while((num = arr.pop()) !== undefined) { // 'readonly number[]' 형식에 'pop' 속성이 없습니다. sum += num; } return sum; }
위 오류를 자세히 보자.
readonly number[]는 '타입'이고 number[]와 구분되는 몇 가지 특징이 있다.
- 배열의 요소를 읽을 수 있지만 쓸 수는 없다.(pop을 비롯한 다른 변경하는 메소드도 호출할 수 없다)
- length를 읽을 수 있지만 바꿀 수는 없다.(역시 배열을 변경하는 것이므로)
number[]는 readonly number[] 보다 기능이 많기 때문에 readonly number[]의 서브타입이 된다.
그래서 number[]를 readonly number[] 타입에 할당할 수는 있지만 그 반대는 안된다.
(number[]가 readonly number[] 보다 더 작은 타입이다)
const a: number[] = [1,2,3]; const b: readonly number[] = a; const c: number[] = b; //~ 'readonly number[]' 타입은 'readonly'이므로 // 변경 가능한 'number[]'타입에 할당될 수 없다.
매개변수를 readonly로 선언하면 다음과 같은 효과를 볼 수 있다.
- 타입스크립트 매개변수가 함수 내에서 변경이 일어나는지 체크한다.
- 호출하는 쪽에서 함수가 매개변수를 변경하지 않는다는 보장을 받게 된다.
- 호출하는 쪽에서 함수에 readonly 배열을 매개변수로 넣을 수 있다.
하지만 어떤 함수를 readonly로 만들면 그 함수를 호출하는 다른 함수도 모두 readonly로 만들어야 한다.
이는 인터페이스를 명확히하고 타입 안정성을 높일 수 있기 때문에 꼭 단점이라고 볼 수는 없다.
그러나 다른 라이브러리에 있는 함수를 호출하는 경우 타입 선언을 바꿀 수 없으므로 타입 단언문(as number[])을 사용해야 한다.
readonly는 다르게 말해 불변성을 보장해준다고 생각하면 된다.
const arr: readonly string[] = []; arr.push('hi'); // 오류. 'readonly string[]' 형식에 'push' 속성이 없습니다. arr.length = 0; // 배열 요소들을 비우는 연산 // 오류. 읽기 전용 속성이기 때문이 'length'에 할당할 수 없습니다.
해당 변수가 가리키는 배열을 교체해주는 연산은 자유롭게 해줄 수 있다.
let arr: readonly string[] = []; arr = []; // 정상. 배열을 비우는 연산. arr = arr.concat(['hi']); // 정상. concat은 원본을 수정하지 않고 새 배열을 반환
즉, 변수가 가리키는 배열은 자유롭게 교체할 수 있지만, 그 배열 자체는 변경할 수 없다.
또한 readonly는 얕게(shallow) 동작한다.
만약 객체의 배열이 readonly하다면, 배열 자체는 readonly 하지만 배열 내부의 객체는 readonly가 아니다.
const dates: readonly Date[] = [new Date()]; dates.push(new Date()); // 오류 dates[0].setFullYear(2037); // 정상
readonly와 비슷한 역할을 하는 객체에 사용되는 Readonly 제너릭도 있다.
Readonly 역시 얕게(shallow) 동작한다.
interface Outer { inner: { x: number; } } const o: Readonly<Outer> = { inner: { x: 0 }}; o.inner = { x: 1 }; // 읽기 전용 속성이기 때문에 'inner'에 할당할 수 없습니다. o.inner.x = 1; // 정상
만약 깊게(deep) 동작하는 Readonly 제네릭이 필요하면 ts-essential에 있는 DeepReadonly 제네릭을 사용하면 된다.
인덱스 시그니처에도 readonly를 쓸 수 있다. 읽기는 허용하되 쓰기를 방지하는 효과가 있다.
let obj: { readonly [k: string]: number } = {}; // 또는 Readonly<{[k: string]: number}> obj.hi = 45; // 오류. ~ 형식의 인덱스 시그니처는 읽기만 허용됩니다. obj = { ...obj, hi: 45 }; // 정상 obj = { ...obj, bye: 45 }; // 정상
위 예시처럼 인덱스 시그니처에 readonly를 사용하면 객체의 속성이 변경되는 것을 방지할 수 있다.
요약
- 만약 함수가 매개변수를 수정하지 않는다면 readonly로 선언하는 것이 좋다. readonly 매개변수는 인터페이스를 명확하게 하며 매개 변수가 변경되는 것을 방지한다.
- readonly를 사용하면 변경하면서 발생하는 오류를 방지할 수 있고 변경이 발생하는 코드도 쉽게 찾을 수 있다.
- const와 readonly의 차이를 이해해야 한다.
- readonly는 얕게 동작한다는 것을 명심하자
아이템 18. 매핑된 타입을 사용하여 값을 동기화하기
아래 예시는 산점도(scatter plot)을 그리기 위한 UI 컴포넌트를 만드는 타입이다.
interface ScatterProps { // The Data xs: number[]; ys: number[]; // Display xRange: [number, number]; yRange: [number, number]; color: string; // Events onClick: (x: number, y: number, index: number) => void; }
이때 위 예시에서 불필요한 작업을 피하기 위해 필요할 때에만 차트를 다시 그리는 최적화 작업을 수행할 수 있다.
데이터나 디스플레이 속성이 변경되면 다시 그려야 하지만 이벤트 핸들러가 변경되면 다시 그릴 필요가 없다.
(이런 종류의 최적화는 리액트에서 일반적, 렌더링할 때마다 이벤트 핸들러 Prop이 새 화살표 함수로 설정)
그렇다면 최적화 하는 두 가지 방법을 알아보자 .
1. 보수적(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; }
만약 새로운 속성이 추가되면 shouldUpdate 함수는 값이 변경될 때마다 차트를 다시 그리게 된다
하지만 이 접근을 이용하면 차트가 정확하지만 너무 자주 그려질 가능성이 있다.
2. 실패에 열린 접근법
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) ); }
이 코드는 차트를 불필요하게 다시 그리는 단점을 해결했지만 차트를 다시 그려야 할 경우 누락되는 일이 생길 수 있다.
(이는 일반적으로 쓰이는 방법이 아니다)
두 방법 모두 이상적이지 않다.
다음은 타입 체커가 동작하도록 매핑된 객체를 사용하여,
새로운 속성이 추가될 때마다 직접 shouldUpdate을 고치도록 개선한 코드이다.
interface ScatterProps { xs: number[]; ys: number[]; xRange: [number, number]; yRange: [number, number]; color: string; onClick: (x: number, y: number, index: number) => void; } 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; }
만약 나중에 ScatterProps에 새로운 속성을 추가하게 되면 REQUIRES_UPDATE의 정의에 오류가 발생한다.
interface ScatterProps { // ... onDoubleClick: () => void; } const REQUIRES_UPDATE: {[k in keyof ScatterProps]: boolean} = { // 'onDoubleClick' 속성이 타입에 없습니다. // ... };
매핑된 타입은 학 객체가 다른 객체와 정확히 같은 속상을 가지게 할 때 유용하다.
위와 같이 매핑된 타입을 사용해 타입스크립트가 코드에 제약을 강제하도록 할 수 있다.
요약
- 매핑된 타입을 사용해서 관련된 값과 타입을 동기화하도록 하자
- 인터페이스에서 새로운 속성을 추가할 때, 선택을 강제해도록 매핑된 타입을 고려해야 한다.
'TypeScript' 카테고리의 다른 글
이펙티브 타입스크립트- 3장 타입추론 (2) (0) 2022.04.10 이펙티브 타입스크립트- 3장 타입추론 (1) (0) 2022.04.03 이펙티브 타입스크립트- 2장 타입시스템 (3) (0) 2022.04.01 이펙티브 타입스크립트- 2장 타입시스템 (2) (0) 2022.03.29 이펙티브 타입스크립트- 2장 타입 시스템 (1) (0) 2022.03.28