자바스크립트에서 객체란 키/값 쌍의 모음입니다.
키는 보통 문자열 / 심벌 입니다. 값은 무엇이든 될 수 있습니다.
키를 숫자로 사용하려고 하면, 자바스크립트 런타임은 문자열로 변환합니다.
> {1: 2, 3: 4}
{'1': 2, '3': 4}
타입스크립트는 이런한 혼란을 바로잡기 위해 숫자 키를 허용하고, 문자열 키와 다른 것으로 인식합니다.
interface Array<T> {
[n: number]: T;
}
const x = [1, 2, 3];
const x0 = x[0] // 정상
const x1 = x['1']; // 인덱스 식이 'number'형식이 아니므로 요소에 암시적으로 'any' 형식이 있습니다.
function get<T>(array: T[], k: string): T {
return array[k] // 인덱스 식이 'number' 형식이 아니므로 요소에 암시적으로 'any' 형식이 있습니다.
}
const keys = Obkect.keys(x); // 타입이 string[]
for (const key in x) {
key; // 타입이 string "1", "2", "3"
const y = x[key]; // 타입이 number 1, 2, 3
}
// ❗️인덱스에 신경 쓰지 않는다면 for-of를 사용하느게 더 좋습니다.
for (const z of x) {
z; // 타입이 number 1, 2, 3
}
// ❗️만약 인덱스 타입이 중요하다면, number 타입을 제공해 줄 Array.prototype.forEach를 사용하면 됩니다.
x.forEach((y, i) => {
i; // 타입이 number 1,2,3
y; // 타입이 number 1,2,3
})
// ❗️루프 중간에 멈춰야 한다면, C 스타일인 for(;;) 루프를 사용하는 것이 좋습니다.
for (let i = 0; i < x.length; i++) {
const y = x[i];
if (x < 0) break;
}
// ⭐️ 타입이 불확실 하다면, for-in 루프는 for-of 또는 C 스타일 for 루프에 비해 몇 배나 느립니다.
인덱스 시그니처가 number로 표현되어 있다면 입력한 값이 number 여야 한다는 것을 의미하지만, 실제 런타임에 사용되는 키는 string 타입입니다.
만약 숫자를 사용하여 인덱스 항목을 지정한다면 Array 또는 튜플 타입을 대신 사용하게 될겁니다.
number를 인덱스 타입으로 사용하면 숫자 속성이 어떤 특별한 의미를 지닌다는 오해를 불러 일으킬 수 있습니다.
// 배열 안의 모든 숫자를 합치는 함수
function arraySum(arr: number[]) {
let sum = 0, num;
while ((num = arr.pop()) !== undefined) {
sum += num;
}
retrun sum;
}
자바스크립트 배열은 내용을 변경할 수 있기 때문에, 타입스크립트에서도 역시 오류없이 통과하게 됩니다.
오류의 범위를 좁히기 위해 readonly 접근 제어자를 사용해 배열을 변경하지 않는다는 선언을 하면 됩니다.
function arraySum(arr: readonly number[]) {
let sum = 0, num;
while ((num = arr.pop()) !== undefined) {
// ~~~~~~ 'readonly number[]' 형식에 'pop' 속성이 없습니다.
sum += num;
}
retrun sum;z
}
위에 코드를 보면 readonly number[]는 '타입'이고, number[]와 구분되는 몇가지 특징이 있습니다.
매개변수를 readonly로 선언하면 다음과 같은 일이 생깁니다.
함수가 매개변수를 변경하지 않는다면, readonly로 선언해야 합니다.
그러나 다른 라이브러리에 있는 함수를 호출하는 경우라면, 타입 선언을 바꿀 수 없으므로 타입 단언문(as number[])을 사용해야 합니다.
interface ScatterProps {
xs: number[];
ys: number[];
xRange: [number, number];
yRange: [number, number];
color: string;
onClick: (x: number, y: number, index: number) => void;
}
function shouldUpdate(oldProps: ScatterProps, newProps: ScatterProps) {
let k: keyof ScatterProps;
for(k in olpProps) {
if (oldProps[k] !== newProps[k]) {
if (k !== 'onClick') return true;
}
}
return false;
}
⭐️ 차트는 정확하지만 너무 자주 그려질 가능성이 있습니다.
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)
)
}
⭐️ 차트를 불필요하게 다시 그리는 단점은 해결했지만 실제로 차트를 다시 그려야 할 경우에 누락되는 일이 생길 수 있습니다.
(일반적인 경우에 쓰이는 방법은 아닙니다.)
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;
}
⭐️ 매핑된 타입은 한 객체가 또 다른 객체와 정확히 같은 속성을 가지게 할 때 이상적입니다.
요약
📌 매핑된 타입을 사용해서 관련된 값과 타입을 동기화 하도록 합니다.
📌 인터페이스에 새로운 속성을 추가할 때, 선택을 강제하도록 매핑된 타입을 고려해야 합니다.
타입스크립트의 많은 타입 구문은 사실 불필요합니다.
let x: number = 12;
// 위에 코드보다 아래 코드처럼 해도 충분합니다.
let x = 12; // x의 추론된 타입이 number 입니다.
// 조금 더 복잡한 객체도 추론할 수 있습니다.
const person: {
name: string;
birthday: string;
phone: string
} = {
name: 'leejs',
birthday: '19950209',
phone: '010-1234-5678'
}
// 아래 코드처럼도 작성해도 충분합니다.
const person = {
name: 'leejs',
birthday: '19950209',
phone: '010-1234-5678'
}
타입 추론이 된다면 명시적 타입 구문은 필요하지 않습니다.
값에 추가로 타입을 작성하는 것은 거추장스러울 뿐입니다.
배열의 경우도 객체와 마찬가지입니다. 타입스크립트는 입력 받아 연산을 하는 함수가 어떤 타입을 반환하는지 정확히 알고 있습니다.
function square(nums: number[]) {
return nums.map(x => x * x);
}
const squares = square([1,2,3]) // 타입은 number[]
타입스크립트는 예상하는 것보다 더 정확하게 추론하기도 합니다.
const axis1: string = "x"; // 타입은 string const axis2 = "y"; // 타입은 "y"
위에 코드의 경우 string으로 예상하기 쉽지만 타입스크립트가 추론한 "y"가 더 정확한 타입입니다.
비구조화 할당문
interface Product {
id: string;
name: string;
price: number;
}
function addProduct(product: Product) {
const id: number = product.id; // string 형식은 number 형식에 할당할 수 없습니다.
const name: string = product.name;
const price: number = product.price;
console.log(id, name, price);
}
// 비구조화 할당문으로 구현할 경우
function addProduct(product: Product) {
const {id, name, price} = product;
console.log(id, name, price);
}
이상적인 타입스크립트 코드는 함수/메서드 시그니처에 타입 구문을 포함하지만, 함수 내에서 생성된 지역 변수에는 타입구문을 넣지 않습니다.
타입 구문을 생략하여 방해되는 것들을 최소화하고 구현 로직에 집중할 수 있게 하는 것이 좋습니다.
타입이 추론될 수 있음에도 타입을 명시하고 싶은 몇 가지 상황이 있습니다.
const elmo: Product = {
name: 'JS',
id: '94382142 39214',
price: 23.14
}
이런 정의에 타입을 명시하면, 잉여 속성 체크가 동작합니다.
const cache: {[ticker: string]: number} = {};
function getQuote(ticker: string): Promise<number> {
if (ticker in cache) {
return cache[ticker];
// ~~~~~~~~~~~~~ 'number' 형식은 'Promise<number>' 형식에 할당할 수 없습니다.
}
}
반환 타입을 명시하면, 구현상의 오류가 사용자 코드의 오류로 표시되지 않습니다.
(Promise와 관련된 특정 오류를 피하는 데는 async 함수가 효과적입니다.)
요약
📌 타입스크립트가 타입을 추론할 수 있다면 타입 구문을 작성하지 않는 게 좋습니다.
📌 이상적인 경우 함수/메서드의 시그니처에는 타입 구문이 있지만, 함수 내의 지역 변수에는 타입 구문이 없습니다.
📌 객체 리터럴과 함수 반환에는 타입 명시를 고려해야 합니다. (내부 구현의 오류가 사용자 코드 위치에 나타나는 것을 방지.)
자바스크립트에서는 한 변수를 다른 목적을 가지는 다른 타입으로 재사용해도 됩니다.
❗️타입스크립트에서는 변수의 값은 바뀔 수 있지만 그 타입은 보통 바뀌지 않습니다.
let id = "123-456";
fetchProduct(id);
id = 123456; // 123456 형식은 string 형식에 할당할 수 없습니다.
fetchProductBySerialNumber(id);
//string 형식의 인수는 number 형식의 매개변수에 할당될 수 없습니다.
타입을 바꿀 수 있는 한 가지 방법.
let id: string|number = "123-456"; // 유니온(union) 타입
fetchProduct(id);
id = 123456; // 정상
fetchProductBySerialNumber(id); // 정상
id를 사용할 때마다 값이 어떤 타입인지 확인해야 하기 때문에 별도의 변수를 도입하는 것이 낫습니다.
const id = "123-456";
fetchProduct(id);
const serial = 123456; // 정상
fetchProductBySerialNumber(serial); // 정상
다른 타입에 별도의 변수를 사용하는 바람직한 이유
"가려지는(shadowed)" 변수
const id = "123-456";
fetchProduct(id);
{
const id = 123456; // 정상
fetchProductBySerialNumber(id); // 정상
}
두 id는 이름은 같지만 실제로는 아무런 관계가 없습니다. (동일한 변수명에 타입이 다름.)
위와 같이 변수를 사용하면 사람에게 혼란을 줄 수 있습니다.
❗️많은 개발팀이 린터 규칙을 통해 "가려지는(shadowed)" 변수를 사용하지 못하도록 하고 있습니다.
요약
📌 변수의 값은 바뀔 수 있지만 타입은 일반적으로 바뀌지 않습니다.
📌 혼란을 막기 위해 타입이 다른 값을 다룰 때에는 변수를 재사용하지 않도록 합니다.
런타임에 모든 변수는 유일한 값을 가집니다.
그러나 타입스크립트가 작성된 코드를 체크하는 정적 분석 시점에, 변수는 "가능한" 값들의 집합인 타입을 가집니다.
넓히기(widening) : 지정된 단일 값을 가지고 할당 가능한 값들의 집합을 유추한다는 뜻.
(상수를 사용해서 변수를 초기화할 때 타입을 명시하지 않으면 타입 체커는 타입을 결정해야 합니다.)
interface Vector3 {x: number; y: number; z: number;}
function getComponent(vector: Vector3, axis: 'x'|'y'|'z') {
return vector[axis];
}
let x = 'x';
let vec = {x: 10, y: 20, z: 30};
getComponent(vec, x);
// ~~~ string 형식의 인수는 "x"|"y"|"z" 형식의 매개변수에 할당될 수 없습니다.
x의 타입은 할당 시점에 넓히기가 동작해서 string으로 추론되었습니다.
❗️ 타입스크립트는 작성자의 의도를 추측합니다. 그러나 타입스크립트가 아무리 영리하더라도 추측한 답이 항상 옳을 수는 없습니다.
let 대신 const로 변수를 선언하면 더 좁은 타입이 됩니다.
const x = 'x'; // 타입이 "x"
let vec = {x: 10, y: 20, z: 30};
getComponent(vec, x); // 정상
x는 재할당될 수 없으므로 타입스크립트는 더 좁은 타입("x")으로 추론할 수 있습니다.
❗️ 객체와 배열의 경우에는 const는 문제가 있습니다.
const v: { x: 1|3|5 } = {
x: 1
}; // 타입이 {x: 1|3|5}
타입 체커에 추가적인 문맥을 제공하는 방법.(함수의 매개변수로 값을 전달)
const 단언문을 사용하는 방법.
(const단언문의 경우 온전히 타입 공간 기법입니다. 변수선언에 쓰이는 let, const 와 혼동하시면 안됩니다.)
const v1 = {
x: 1,
y: 2,
}; // 타입은 { x: number; y: number; }
const v2 = {
x: 1 as const,
y: 2,
} // 타입은 { x: 1; y: number; }
const v3 = {
x: 1,
y: 2,
} as const; // 타입은 { readonly x: 1; readonly y: 2; }
const a1 = [1,2,3] // 타입이 number[]
const a2 = [1,2,3] as const; // 타입이 readonly [1,2,3]
값 뒤에 as const 를 작성할 경우 타입스크립트는 최대한 좁은 타입으로 추론합니다.
⭐️ 넓히기로 인해 오류가 발생할 시, 명시적 타입 구문 또는 const 단언문을 추가하는 것을 고려해야 합니다.
요약
📌 동작에 영향을 줄 수 있는 방법인 const, 타입 구문, 문맥, as const에 익숙해져야 합니다.
타입 좁히기 : 타입스크립트가 넓은 타입으로부터 좁은 타입으로 진행하는 과정을 말합니다.
const el = document.getElementById('foo'); // 타입이 HTMLElement | null
if (el) {
el // 타입이 HTMLElement
el.innerHTML = 'Party time'.blink();
} else {
el // 타입이 null
alert('No element #foo');
}
타입 체커는 일반적으로 이러한 조건문에서 타입 좁히기를 잘 해내지만, 타입 별칭이 존재한다면 그러지 못할 수도 있습니다.
function contains(text: string, search: string|RegExp) {
if (search instanceof RegExp) {
search // 타입이 RegExp
return !!search.exec(text);
}
search // 타입이 string
return text.includes(search);
}
interface A { a: number }
interface B { b: number }
function pickAB(ab: A | B) {
if ('a' in ab) {
ab // 타입이 A
} else {
ab // 타입이 B
}
ab // 타입이 A | B
}
function contains(text: string, terms: string|string[]) {
const termList = Array.isArray(terms) ? terms : [terms];
termList // 타입이 string[]
// ...
}
타입스크립트는 일반적으로 조건문에서 타입을 좁히는 데 매우 능숙합니다.
interface UploadEvent { type: 'upload'; filename: string; contents: string; }
interface DownloadEvent { type: 'download'; filename: string; }
type AppEvent = UploadEvent | DownloadEvent;
function handleEvent(e: AppEvent) {
switch (e.type) {
case 'download':
e; // 타입이 DownloadEvent
break;
case 'upload':
e; // 타입이 UploadEvent
break;
}
}
위 패턴은 '태그된 유니온(tagged union)' 또는 '구별된 유니온(discriminated union)'이라고 불립니다.
식별을 돕기 위해 커스텀 함수를 도입할 수 있습니다.
function isInputElement(el: HTMLElement): el is HTMLInputElement {
return 'value' in el;
}
function getElementContent(el: HTMLElement) {
if (isInputElement(el)) {
el; // 타입이 HTMLInputElement
return el.value;
}
el; // 타입이 HTMLElement
return el.textContent;
}
이러한 기법을 '사용자 정의 타입 가드'라고 합니다.
const jackson5 = ['jackie', 'Tito', 'Jermaine', 'Marlon', 'Michael'];
const members = ['Janet', 'Michael'].map(
who => jackson5.find(n => n === who)
); // 타입이 (string | undefined)[]
// filter 함수를 사용해도 잘 동작하지 않을 것입니다.
const members = ['Janet', 'Michael'].map(
who => jackson5.find(n => n === who)
).filter(who => who !== undefined); // 타입이 (string | undefined)[]
// 이럴 때 타입 가드를 사용하면 타입을 좁힐 수 있다.
function isDefined<T>(x: T | undefined): x is T {
return x !== undefined;
}
const members = ['Janet', 'Michael'].map(
who => jackson5.find(n => n === who)
).filter(isDefined); // 타입이 string[]
요약
📌 태그된/구별된 유니온과 사용자 정의 타입 가드를 사용하여 타입 좁히기 과정을 원활하게 만들 수 있습니다.