
✔️ JS 의 객체 모델
JS에서 객체란 키/값 쌍의 모음이다.
키는 보통 문자열이고 값은 어떤 것이든 될 수 있다.
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에 할당될 수 없기 때문에 마지막 줄이 동작하는 것이 이상해보이나, 배열을 순회하는 코드 스타일에 대한 실용적인 허용이라 생각하면 된다.
❗하지만 위 방법은 배열을 순회하기에 좋은 방법이 아니다.
for (const x of xs) {
x; // number
}
xs.forEach((x,i) => {
i; //number
x; //number
})
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 타입을 사용하는 것이 좋다.
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[]와 구분되는 몇 가지 특성이 있다.
JS에서는 명시적으로 언급하지 않는 한, 함수가 매개변수를 변경하지 않는다고 가정한다.
하지만 이런 암묵적인 방법은 타입 체크에 문제를 일으킬 수 있으니 명시적인 방법을 사용하자.
이를 활용하여 위 arraySum 함수를 배열을 변경하지 않도록 수정해보면,
function arraySum(arr: readonly number[]) {
let sum = 0;
for(const num of arr) {
sum += num;
}
return sum;
}
만약 함수가 매개변수를 변경하지 않는다면, 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가지 방법
para.push([...currPara])
readonly string[] 의 배열로 변경한다.const para: (readonly string[])[] = [];
// 여기서 괄호가 중요하다.
// readonly string[][] 은 변경 가능한 배열의 readonly 배열이라는 뜻이고
// (readonly string[])[] 은 readonly 배열의 변경가능한 배열이라는 뜻이다.
// 위 코드에서는 currPara가 readonly 배열로 바뀌었으니
// 결과물인 para도 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의 차이를 이해해야 한다. (?)