Effective Typescript (Day 5)

d_fe·2022년 11월 29일
post-thumbnail

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

  • JS는 암시적 타입 강제와 관련해 아주 이상하게 동작한다.
  • 객체 모델에도 이상한 부분들이 있으며, 이 중 일부는 TS 타입 시스템으로 모델링되게 때문에 JS 객체 모델을 이해하는 것이 중요하다. (ex. 래퍼 타입)

✔️ JS 의 객체 모델

JS에서 객체란 키/값 쌍의 모음이다.
키는 보통 문자열이고 값은 어떤 것이든 될 수 있다.

  • JS에는 '해시 가능' 객체라는 표현이 없기 때문에 복잡한 객체를 키를 사용하려고 하면, tostring 메서드가 호출되어 객체가 문자열로 반환된다.
  • 숫자는 키로 사용할 수 없다. 사용하려 하면 문자로 변경될 것이다.
  • 배열은 객체다. 그러니 숫자 인덱스를 사용하는 것이 당연하다.
    (객체는 키값이 문자열이니 이상하게 보이지만, 인덱스들은 문자열로 변환되어 사용된다.)
    (문자열 키를 사용해도 접근 가능하다. x['1'])
  • Object.keys를 이용해보면 키가 문자열로 출력된다.

✔️ 타입스크립트는 이런 혼란을 바로잡기 위해 숫자 키를 허용하고, 문자열 키와 다른것으로 인식한다.

// lib.es5.d.ts 에 선언된 Array 타입 선언
interface Array<T> {
    // ...
    [n: number] : T
}

런타임에는 ECMAscript 표준이 서술하는 것처럼 문자열 키로 인식하므로 코드는 완전히 가상이라 할 수 있지만, 타입 체크 시점에 오류를 잡을 수 있다.

타입 정보는 런타임에 제거되지만 Object.keys 같은 구문은 여전히 문자열로 반환된다.

const xs = [1,2,3]
const keys = Object.keys(xs); // string[]
for (const key in xs) {
	key;					  // string
  	const x = xs[key]		  // number
}

string이 number에 할당될 수 없기 때문에 마지막 줄이 동작하는 것이 이상해보이나, 배열을 순회하는 코드 스타일에 대한 실용적인 허용이라 생각하면 된다.

❗하지만 위 방법은 배열을 순회하기에 좋은 방법이 아니다.

  1. 인덱스에 신경 쓰지 않는다면 'for - of'을 사용하는 게 더 좋다.
for (const x of xs) {
	x; 						  // number
}
  1. 인덱스 타입이 중요하다면, number 타입을 제공해 줄 Array.prototype.forEach를 사용
xs.forEach((x,i) => {
	i;						  //number
  	x;						  //number
})
  1. 루프 중간에 멈춰야 한다면, for(;;)를 사용하는 것이 좋다.
for(let i=0; i<xs.length; i++) {
	const x = xs[i];
  	if (x<0) break;
}

++ for ...in 과 for ...of

for ...in : 모든 객체의 key 값을 순회하며, value에 직접 접근할 수 없다.
순서가 보장되지 않고, length 연산을 사용할 수 없으며 value값은 string이라 연산이 불가하다.

for ...of : 반복 가능한(iterable) 객체의 value를 순회한다.

! 타입이 불확실하다면 for ...in은 for ...of, for(;;) 에 비해 몇 배나 느리다.

다시 배열 타입으로 돌아와서,
어떤 길이를 가지는 배열과 비슷한 형태의 튜플을 사용하고 싶다면 ArrayLike타입을 사용한다.

function checkedAccess<T>(xs: ArrayLike<T>, i:number):T {
	if(i < xs.length) {
    	return xs[i];
    }
  	throw new Error(`배열의 끝을 지나서 ${i}를 접근하려 했습니다`);
}

위 예제는 길이와 숫자 인덱스 시그니처만 있다. 이런 경우가 실제로 드물지만 필요하다면 ArrayLike를 사용해야 한다. 하지만 키는 여전히 문자열이라는 점을 잊지 말자.

//lib.es5.d.ts 에 정의된 ArrayLike<T> 타입
interface ArrayLike<T> {
    readonly length: number;
    readonly [n: number]: T;
}

☑️ 배열은 객체이므로 키는 숫자가 아니라 문자열이다. 인덱스 시그니처로 사용된 number타입은 버그를 잡기 위한 순수 타입스크립트 코드다.
☑️ 인덱스 시그니처에 number를 사용하기보다 Array나 튜플, 또는 ArrayLike 타입을 사용하는 것이 좋다.


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

function printTri(n: number) {
    const nums = [];
    for (let i=0; i< n; i++) {
        nums.push(i);
        console.log(arraySum(nums));
    }
}

function arraySum(arr: number[]) {
    let sum = 0, num;
    while((num = arr.pop()) !== undefined) {
        sum += num;
    }
    return sum;
}

위 함수는 배열 안의 숫자들을 모두 합치지만 계산이 끝나면 원래 배열이 모두 비게 된다.
JS 배열은 내용을 변경할 수 있으므로 TS에서도 오류 없이 통과한다.

오류의 범위를 좁히기 위해 arraySum이 배열을 변경하지 않는다는 선언을 해보기 위해 readonly 접근 제어자를 사용한다.

function arraySum(arr: readonly number[]) {
    let sum = 0, num;
    while((num = arr.pop()) !== undefined) {
        // ~~ 'readonly number[]' 형식에 'pop' 속성이 없습니다.
        sum += num;
    }
    return sum;
}

readonly number[] 은 '타입'이고, number[]와 구분되는 몇 가지 특성이 있다.

  • 배열의 요소를 읽을 수 있지만, 쓸 수 없다.
  • length를 읽을 수 있지만, 바꿀 수는 없다 (배열을 변경함)
  • 배열을 변경하는 pop을 비롯한 다른 메서드를 호출할 수 없다.

➡️ 매개변수를 readonly로 선언하면 다음과 같은 일이 생긴다.
  • 타입스크립트는 매개변수가 함수 내에서 변경이 일어나는지 체크한다.
  • 호출하는 쪽에서는 함수가 매개변수를 변경하지 않는다는 보장을 받게 된다.
  • 호출하는 쪽에서 함수에 readonly 배열을 매개변수로 넣을 수도 있다.

JS에서는 명시적으로 언급하지 않는 한, 함수가 매개변수를 변경하지 않는다고 가정한다.
하지만 이런 암묵적인 방법은 타입 체크에 문제를 일으킬 수 있으니 명시적인 방법을 사용하자.

이를 활용하여 위 arraySum 함수를 배열을 변경하지 않도록 수정해보면,

function arraySum(arr: readonly number[]) {
    let sum = 0;
    for(const num of arr) {
        sum += num;
    }
    return sum;
}

만약 함수가 매개변수를 변경하지 않는다면, readonly로 선언해야 더 넓은 타입으로 호출할 수 있고, 의도치 않은 변경은 방지될 것이다.

readonly의 단점을 굳이 찾아보면,

  • 어떤 함수를 readonly로 만들면, 그 함수를 호출하는 다른 함수도 모두 readonly로 만들어야 한다. (이게 더 명확하기 때문에 딱히 단점이라 하긴 그렇다 한다.)
  • 다른 라이브러리에 있는 함수를 호출하는 경우라면, 타입 선언을 바꿀 수 없으므로 타입 단언문을 사용해야 한다.

✔️readonly를 사용하면 지역 변수와 관련된 모든 종류의 변경 오류를 방지할 수 있다.

아래 연속된 행을 가져와서 빈 줄을 기준으로 구분되는 단락으로 나누는 기능을 하는 코드가 있다.

function parseTaggedText(lines: string[]) : string[][] {
    const para : string[][] = [];
    const currPara : string[] = [];

    const addPara = () => {
        if (currPara.length) {
            para.push(currPara)  // 오류발생지점
            currPara.length = 0; // 오류발생지점
        }
    }

    for (const line of lines) {
        if (!line) {
            addPara();
        } else {
            currPara.push(line);
        }
    }
    addPara();
    return para;
}

// output : [[],[],[]]

위 오류 발생 지점을 살펴보면 para.push(currPara) 는 내용이 삽입되지 않고 배열의 참조가 삽입되었다. 따라서 currPara 의 길이가 0이 되면 para로 들어간 currPara의 길이 또한 0이 되어 빈 배열로 반환되었다. 따라서 이 코드는 새 단락을 para에 집어넣고 바로 지워 버린다.

위 오류를 해결하기 위해 readonly 를 사용할 수 있다.

function parseTaggedText(lines: string[]) : string[][] {
    const para : string[][] = [];
    const currPara : readonly string[] = [];

    const addPara = () => {
        if (currPara.length) {
            para.push(currPara)
          	// 1. readonly string[]속성의 인수는 string[]에 할당할 수 없습니다.
            currPara.length = 0;
          	// 2. 읽기 전용 속성으로 'length'에 할당할 수 없습니다.
        }
    }

    for (const line of lines) {
        if (!line) {
            addPara();
        } else {
            currPara.push(line);
          	// 3. readonly string[] 형식에 push 속성이 없습니다.
        }
    }
    addPara();
    return para;
}

위 주석처리된 오류 내용 중 2,3 번은 currPara를 변경하지 않는 방식을 사용하여 해결할 수 있다.

❗오류 2 해결) currPara를 let으로 선언하고 currPara = []으로 선언한다.
❗오류 3 해결) push는 원본을 수정하므로 concat을 이용하여 새 배열을 반환한다.

❗오류 2,3 해결 후 오류 1을 해결하기 위한 3가지 방법

  1. currPara의 복사본을 만든다.
    @ 복사본은 원하는대로 변경이 가능하다.
para.push([...currPara])
  1. para를 readonly string[] 의 배열로 변경한다.
const para: (readonly string[])[] = [];
// 여기서 괄호가 중요하다.
// readonly string[][] 은 변경 가능한 배열의 readonly 배열이라는 뜻이고
// (readonly string[])[] 은 readonly 배열의 변경가능한 배열이라는 뜻이다.

// 위 코드에서는 currPara가 readonly 배열로 바뀌었으니
// 결과물인 para도 readonly 배열의 변경 가능한 배열로 바뀌어야 한다고 생각하면 쉽다.
  1. 배열의 readonly 속성을 제거하기 위해 단언문을 사용한다.
para.push(currPara as string[]);

✔️ readonly 는 얕게 동작한다는 점을 유의해야 한다.

만약 객체의 readonly 배열이 있다면, 그 객체 자체는 readonly가 아니다.

const dates: readonly Date[] = [new Date()];
dates.push(new Date()); // Error . readonly Date[] 형식에 push 속성이 없습니다.
dates[0].setFullyear(2000); // 정상

비슷한 예시를 Readonly 제너릭에서도 볼 수 있다.

interface Outer {
    inner: {
        x: number
    }
}

const o: Readonly<Outer> = {inner: {x:0}}
o.inner = {x:1}; // Error. 읽기 전용 속성이기 때문에 inner에 할당할 수 없습니다.
o.inner.x = 1;

//타입 별칭을 만들면 어떻게 동작했는 지 알 수 있다.
type T = Readonly<Outer>
// Type T = {
//     readonly inner: {
//         x:number;
//     }
// }

여기서 readonly 접근제어자는 inner에 적용된 것이지 x는 아니라는 것을 알아챌 수 있다.

++ 인덱스 시그니처에도 readonly를 쓸 수 있다.

현재 깊은 readonly 타입이 기본으로 지원되고 있지 않지만 제너릭을 통해 만들 수 있다.
하지만 이 방식은 까다롭기 때문에 라이브러리를 사용하는게 낫다.
ex) ts-essentials 의 DeepReadonly 제너릭

☑️ 만약 함수가 매개변수를 수정하지 않는다면 readonly로 선언하는 것이 좋다. readonly 매개변수는 인터페이스를 명확하게 하며, 매개변수가 변경되는 것을 방지한다.
☑️ readonly를 사용하면 변경하면서 발생하는 오류를 방지할 수 있고, 변경이 발생하는 코드도 쉽게 찾을 수 있다.
☑️ readonly 는 얕게 동작한다.
☑️ const와 readonly의 차이를 이해해야 한다. (?)

profile
오늘보다 내일 더 성장하는 프론트엔드 개발자가 되기 위해

0개의 댓글