이펙티브 타입스크립트:2장 아이템16-18

이펙티브 타입스크립트 북스터디 2장 아이템16-18

이펙티브 타입스크립트

동작 원리의 이해와 구체적인 조언 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

함수1

기댓값 0 1 3 6 10 과 다른 값이 출력되었습니다. arraySum 이 nums 를 변경하지 않는다고 간주해서 문제가 발생한 것 입니다. 실제로 arraySum 은 pop() 을 사용해 매개변수 arr 을 변경하고 있습니다.

함수2

계산이 끝나면 배열 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 함수가 배열을 변경하지 않는다고 아래와 같이 선언합니다.

함수3

매개변수로 받은 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 배열을 변경한다는 점입니다.

코드를 다음과 같이 수정해볼 수 있습니다

  1. currPara 를 readonly 로 선언
const currPara: readonly string[] = [];
  1. currPara 를 let 으로 선언
let currPara: radonly string[] = [];
...
currPara = []; //Clear the lines
  1. 변환이 없는 메서드를 사용
  • 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 인 경우에만 업데이트를 하게되었습니다.

요약

  • 매핑된 타입을 사용해서 관련된 값과 타입을 동기화하도록 합니다.
  • 인터페이스에 새로운 속성을 추가할 때, 선택을 강제하도록 매핑된 타입을 고려해야 합니다.

디지엠유닛원 주식회사

  • 대표이사 권혁태
  • 개인정보보호책임자 정경영
  • 사업자등록번호 252-86-01619
  • 주소
    서울특별시 금천구 가산디지털1로 83, 6층 601호(가산동, 파트너스타워)
  • 이메일 unit1@dgmit.com