이펙티브 타입스크립트
동작 원리의 이해와 구체적인 조언 62가지
아이템16 인덱스 시그니처보다는 Array, 튜플, ArrayLike 를 사용하기
자바스크립트의 객체는 키, 값 쌍의 모음입니다. 키는 보통 문자열이고, 값은 어떤 것이든 될 수 있습니다. 속성 이름으로 숫자를 사용하려고하면 자바스크립트 런타임은 문자열로 변환합니다
>{1: 2, 3: 4}
{'1':2, '3':4}
문자열을 사용해도 배열에 접근할 수 있고, 배열의 Key 를 나열하면 키가 문자열로 출력됩니다.
x = [] // 배열 선언
x = [1,2,3] // 배열 할당
x[0] // 호출 : 1
x['1'] // 호출 : 2
Object.keys(x) // ['0','1','2']
타입스크립트는 위와 같은 혼란을 바로잡기 위해 숫자키를 허용합니다. 런타임에는 문자열 키로 인식하지만, 타입체크시점에 오류를 잡을 수 있습니다.
interface Array<T>{
[n: number] : T;
}
Object.keys 는 여전히 문자열로 반환합니다. 마지막 줄에서는 string 이 number 에 할당되고 있으나 코드는 잘 동작합니다.
const xs = [1,2,3];
const keys = Object.keys(xs); //타입 : string[]
for (const key in xs){ // for-in
key; // '1' 타입 : string
const x = xs[key]; //xs[1] : 2
}
//인덱스 타입이 중요하지 않다면 for-of
for (const x of xs){
x;
}
//인덱스의 타입이 중요하다면 forEach
xs.forEach((x,i)=>{
i; // 현재 요소의 인덱스 : number
x; // x로 할당됨 : number
});
//중간에 멈춰야한다면
for(let i=0; i<xs.length; i++){
const x = xs[i];
if(x<0) break;
}
number 를 인덱스 타입으로 사용하면 숫자 속성이 의미를 지닌다는 오해를 불러일으킬 수 있으니 숫자를 사용해 인덱스할 항목을 지정할 땐 Array 또는 튜플 타입을 사용합니다.
const row: number[] = [1, 2, 3];
type Row = [number, number, number];
const row: Row = [1, 2, 3];
배열과 비슷한 형태의 튜플을 사용하고 싶다면 ArrayLike 타입을 사용합니다
function checkedAccess<T>(xs: ArrayLike<T>, i: number): T {
if (i < xs.length) {
return xs[i];
}
throw new Error(`배열의 끝을 지나서 ${i}를 접근하려고 했습니다`);
}
요약
- 배열은 객체이므로 키는 수자가 아니라 문자열입니다. 인덱스 시그니처로 사용된 number 타입은 버그를 잡기 위한 순수 타입스크립트 코드입니다.
- 인덱스 시그니처에 number 를 사용하기 보다 Array 나 튜플, 또는 ArrayLike 타입을 사용하는 것이 좋습니다.
아이템17 변경 관련된 오류 방지를 위해 readonly 사용하기
매개변수로 받은 배열의 합을 구하는 함수 arraySum
function arraySum(arr: number[]){
let sum = 0, num;
while((num = arr.pop()) !== undefined){
sum += num;
}
return sum;
}
삼각수를 출력하는 함수 printTriangles
기댓값 0 1 3 6 10 과 다른 값이 출력되었습니다. arraySum 이 nums 를 변경하지 않는다고 간주해서 문제가 발생한 것 입니다. 실제로 arraySum 은 pop() 을 사용해 매개변수 arr 을 변경하고 있습니다.
계산이 끝나면 배열 arr 은 비게 되고, 다음과 반복문을 돌면서 각 변수들은 다음과 같은 값들을 갖게 됩니다.
1회차:
i = 0
nums = [0]
sum 0
nums = [] //pop 이후 nums 는 다시 빈배열이 됨
2회차:
i = 1
nums = [1]
sum 1
nums = []
3회차:
i = 2
nums = [2]
sum 2
nums = []
...
오류를 좁히기 위해 readonly 를 사용해 arraySum 함수가 배열을 변경하지 않는다고 아래와 같이 선언합니다.
매개변수로 받은 readonly 배열을 변경하지 않으면 위 오류를 없앨 수 있습니다. 배열에서 pop 을 사용하지 않고 순회해서 더하는 것으로 변경해보겠습니다.
function arraySum(arr: readonly number[]) {
let sum = 0
for (const num of arr) {
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 3 6 10
함수가 매개변수를 변경하지 않을 때 readonly 로 선언한다면
- 더 넓은 타입으로 호출할 수 있고
- 의도치 않은 변경을 방지할 수 있습니다
연속된 행을 가져와서 빈 줄을 기준으로 구분되는 단락으로 나누는 기능을 하는 함수 parseTageedText
function parseTaggedText(lines: string[]): string[][] {
const paragraphs: string[][] = []
const currPara: string[] = []
const addParagraph = () => {
if (currPara.length) {
paragraphs.push(currPara)
currPara.length = 0 // Clear the lines
}
}
for (const line of lines) {
if (!line) {
addParagraph()
} else {
currPara.push(line)
}
}
addParagraph()
return paragraphs
}
const str = `Lorem ipsum ....`
console.log(parseTaggedText(str.split('\n'))) //[ [], [], [] ]
addParagraph() 함수 내부의
paragraphs.push(currPara)
currPara.length = 0
에서 currPara 는 같은 객체입니다. currPara의 길이를 0으로 만들어줌으로서 paragraphs배열에 추가된 currPara 의 길이도 0이 됩니다. 따라서 빈 배열이 출력되었고, 문제는 currPara.length 를 수정하고 currPara.push 를 호출하면 둘 다 currPara 배열을 변경한다는 점입니다.
코드를 다음과 같이 수정해볼 수 있습니다
- currPara 를 readonly 로 선언
const currPara: readonly string[] = [];
- currPara 를 let 으로 선언
let currPara: radonly string[] = [];
...
currPara = []; //Clear the lines
- 변환이 없는 메서드를 사용
- push 와 달리 concat 은 원본을 수정하지 않고 새 배열을 반환합니다
currPara = currpara.concat([line]);
readonly number[] 타입
- number[] 는 readonly number[] 의 서브타입이다
- 배열의 요소를 읽기만 가능하다
- lenght 를 읽을 수 있지만, 바꿀 수 없다
- 배열을 변경하는 메서드를 호출할 수 없다 (pop, push, slice ..)
- readonly 배열에 변경가능한 배열(a)을 할당할 수는 있지만, 변경가능한 배열에 readonly 배열을 할당하는 것은 불가하다.
매개변수를 readonly로 선언했을 때
- 타입스크립트는 매개변수가 함수 내에서 변경이 일어나는지 체크한다
- 호출하는 쪽에서는 함수가 매개변수를 변경하지 않는다는 보장을 받게된다
- 호출하는쪽에서 함수에 readonly 배열을 매개변수로 넣을 수도 있다
요약
- 만약 함수가 매개변수를 수정하지 않는다면 readonly 로 선언하는 것이 좋다. reaonly 매개변수는 인터페이스를 명확하게 하며, 매개변수가 변경되는 것을 방지한다.
- readonly 를 사용하면 변경하면서 발생하는 오류를 방지할 수 있고 변경이 발생하는 코드도 쉽게 찾을 수 있다
- const 와 readonly의 차이를 이해해야 한다
- readonly 는 얕게 동작한다는 것을 명심해야 한다
아이템18 매핑된 타입을 사용하여 값을 동기화하기
산점도(Scattter plot) : 좌표상의 점들을 표시함으로써 두 개 변수 간의 관계를 나타내는 그래프 방법
산점도를 그리기 위한 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;
}
데이터나 디스플레이 속성이 변경되는 것 처럼 필요할 때에만 차트를 다시 그리고, 이벤트 핸들러가 변경되면 다시 그릴 필요가 없습니다. 이 코드를 아래와 같이 최적화 할 수 있습니다.
보수적 접근법
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;
}
props 얕은 비교를 진행해서 onClick 이 바뀐 것이 아니면 true 를 리턴합니다. (onClick 은 익명함수로 매번 새로 선언되기 때문에 예외처리를 해준 것) 보수적 접근법을 이용하면 차트가 정확하지만 너무 자주 그려질 가능성이 있습니다.
실패에 열린 접근법
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)
)
}
하나씩 일일이 비교하고, onclick 에 대해서는 비교하지 않습니다.
실패에 열린 접근법은 차트를 불필요하게 다시 그리는 단점을 해결했지만 실제로 차트를 다시 그려야 할 경우에 누락되는 일이 생길 수도 있습니다. ScatterProps 객체에 새로운 속성이 추가되면 그 속성은 변경을 감지할 수 없기 때문입니다.
보수적 접근법과 실패에 열린 접근법 모두 이상적이지 않습니다.
아래는 타입 체커가 동작하도록 개선한 코드이다. 핵심은 매핑된 타입과 객체를 사용하는 것 입니다.
const REQUIRES_UPDATE: { [k in keyof ScatterProps]: boolean } = {
xs: true,
ys: true,
xRange: true,
yRange: true,
color: true,
onClick: false,
}
[k in keyof ScatterProps] 는 REQUIRES_UPDATE 객체는 ScatterProps 와 동일한 속성을 가져야 한다는 정보를 제공합니다. 만약 ScatterProps 속성이 변경된다면 이를 타입체커가 감지해서 오류를 발생시켜줍니다. 이런 방식은 오류를 정확히 잡아냅니다.
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
}
oldProps 와 newProps 값이 다르고 REQUIRES_UPDATE 객체에서 k 속성의 값이 true 인 경우에만 업데이트를 하게되었습니다.
요약
- 매핑된 타입을 사용해서 관련된 값과 타입을 동기화하도록 합니다.
- 인터페이스에 새로운 속성을 추가할 때, 선택을 강제하도록 매핑된 타입을 고려해야 합니다.