타입스크립트에서 타입 추론은 매우 편리하며 하나하나 타입을 정의하지 않아도 되게 만들어 준다는 점에서 개발자가 타입에 대하여 신경써야 할 부분들을 줄여준다.
하지만 나의 의도와는 다르게 타입을 추론하게 될 수 있으므로 어떻게 동작할 것인지 예측하는 것은 디버깅을 더욱 쉽게 만든다.
타입스크립트에서 타입을 좁히는 과정을 알아보도록 하자. 타입을 좁혀나간다는 것은 어떤 의미일까?
지난 글에서 타입을 넓히기 때문에 상수와 타입이 추론된다는 점을 이해하였다. 이번 글에서는 타입 좁히기를 통하여 넓은 타입에서 작은 타입으로 타입을 체크하는 것을 지켜보고자 한다. 타입을 좁히는 방법에는 여러 가지가 존재한다.
const el = document.getElementById('foo'); //타입이 HTMLElement | null
if (el) {
el //타입이 HTMLElement
el.innerHTML = 'Party Time'.blink();
} else {
el //타입이 null
alert('No element #foo');
}
만약 el
이 null
이라면, 분기문의 첫번째 블록이 실행되지 않는다. 즉, 첫 번째 블록에서 HTMLElement | null
타입의 null
을 제외하기 때문이다.
그래서 더 좁은 타입이 되어서 작업이 훨씬 수월해진다.
이러한 과정을 타입 좁히기 라고 한다.
const el = document.getElementById('foo');
if (!el) throw new Error('Unable to find #foo');
el;
el.innerHTML = 'Party Time'.blink();
예외를 사용하여 null
타입을 통과한 경우에는 HTMLElement
타입으로 둬 타입을 좁혀간다.
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
}
위와 같이 타입을 좁히는 데에는 if
문이 매우 잘 사용된다. 그러나, if
문을 사용하여 타입을 좁힐 때 주의해야 할 점이 존재한다.
const el = document.getElementById('foo'); //타입은 HTMLElement | null
if (typeof el === 'object') {
el;
}
타입스크립트에서 typeof null
은 object
이기 때문에, 조건문에서 null
이 올바르게 제외되지 않는다. 이런 부분들에서 에러가 발생하지 않도록 주의하자.
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
break;
case 'upload':
e;
break;
이러한 패턴은 태그된 유니온 또는 구별된 유니온이라고 부른다
타입을 식별하지 못할 때, 식별을 돕기 위하여 커스텀 함수를 작성할 수 있다.
여기서 내가 크게 공감했던 코드가 있는데, 타입스크립트를 이용하여 배열을 작성할 때 가장 많이 마주쳤던 친구이다..
const jackson5 = ['Jackie', 'Tito', 'Jermaine', 'Marlon', 'Michael']
const memebers = ['Janet', 'Michael'].map(
who => jackson5.find(n => n === who)
); //타입은 (string | undefined)[]
여기서 undefined
가 존재하기 때문에 작성하고 싶은 코드를 자유롭게 작성하지 못한 경우가 많다. 이 때 나는 filter
함수를 이용하여 undefined
라고 찍히는 친구들을 걸러내고 싶었는데...
const memebers = ['Janet', 'Michael'].map(
who => jackson5.find(n => n === who)
).filter (who => who !== undefined); //타입이 (string | undefined)[]
이렇게 코드를 작성하여도 undefined
는 걸러지지 않는다.. ㅠㅠ
이럴 때, 타입 가드를 작성하면 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[]
이제 배열에 존재하는 undefined
가 안지워진다고 고생하지 말고 타입 가드 함수를 작성하자~
타입스크립트에서 타입은 일반적으로 동적으로 변경되지 않는다. 그래서 객체를 생성할 때에는 여러 속성을 포함하여 한꺼번에 생성해야, 타입 추론에 유리하다.
const pt = {};
pt.x = 3;
pt.y = 4;
이렇게 작성하게 되면 타입스크립트의 할당문에는 오류가 발생하게 된다. 왜냐하면 pt
타입은 {}
값을 기준으로 타입을 추론하기 때문이다.
이러한 문제는 객체를 한번에 정의해야 해결할 수 있다.
만약 객체를 반드시 제각각 나누어 만들어야겠다면, 타입 단언문을 이용해야 한다.
const pt = {} as Point;
pt.x = 3;
pt.y = 4;
const pt = { x: 3, y: 4 }
const id = { name: 'Pythagoras' }
const namePoint = { ...pt, ...id };
객체 전개 연산자인 ...
을 사용하면 큰 객체를 한꺼번에 만들 수 있다.
declare let hasMiddle: boolean;
const firstLast = {first: 'Harry', last: 'Truman'};
const president = {...firstLast, ...(hasMiddle ? {middle: 'S'} : {})};
편집기에서 president에 마우스를 올려보자.
그럼 이렇게 선택적 속성으로 표기된다.
그런데 전개 연산자로 두 개 이상의 속성을 추가한다면?
declare let hasDates: boolean;
const nameTitle = { name: 'Khufu', title: 'Pharaoh'};
const pharaoh = {
...nameTitle,
...(hasDates? {start: -2589, end: -2566} : {})
};
이 경우에는 start
와 end
가 항상 함께 정의된다. 그리고 타입에서 각각의 속성을 읽어올 수가 없게 된다. 이럴 때에는 선택적 필드를 이용하여 헬퍼 함수로 원하는 대로 표현해볼 수 있겠다.
function addOptional<T extends object, U extends object>( a: T, b: U | null ): T & Partial<U> {
return {...a, ...b};
}
const pharaoh = addOptional(
nameTitle,
hasDates ? {start: -2589, end: -2566} : null
);
타입스크립트는 별칭을 사용하여 반복을 줄 일 수 있다.
const borough = {name: 'Brooklyn', location: [40.688, -73]};
const loc = borough.location;
이렇게 별칭을 지정하고 별칭의 값을 변경한다면, 원래 속성값도 변경이 된다.
그러나, 이렇게 별칭을 남발하면 제어 흐름을 분석하기 어렵다. 별칭을 신중하게 사용해야지 좋은 코드를 작성할 수 있다.
interface Polygon {
exterior: Coordinate[];
holes: Coordinate[][];
bbox?: BoundingBox;
}
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
const box = polygon.bbox;
if (polygon.bbox) {
if (pt.x < box.x[0] || pt.x > box.x[1] || pt.y < box.y[0] || pt.y > box.y[1]) {
return false;
}
}
}
여기서는 box.x
가 undefined
라는 이유로 밑줄이 쳐진다. 그 이유를 살펴보자!
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
polygon.bbox // 타입은 BoundingBox | undefined
const box = polygon.bbox; // 타입은 BoundingBox | undefined
if (polygon.bbox) {
polygon.bbox // 타입은 BoundingBox
box // 타입은 BoundingBox | undefined
if (pt.x < box.x[0] || pt.x > box.x[1] || pt.y < box.y[0] || pt.y > box.y[1]) {
return false;
}
}
}
위와 같이 별칭을 사용해놓고 어떨 땐 사용 안하고.. 어떨 땐 사용하지 않는 일관성 있지 않은 코드를 작성하게 되면 당연히 타입이 그때그때 달라지는 것이다. polygon.bbox
만 타입을 정제하는 데 성공했기 때문이다. 별칭을 일관성 있게 사용한다는 기본 원칙을 지키면 방지할 수 있는 에러이다.
코드를 읽는 이에게는 bbox
가 box
로 사용되어 혼란을 초래한다. 그렇기 때문에 객체 비구조화를 사용하여 같은 이름으로 최대한 사용할 수 있다.
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
const {bbox} = polygon;
if (bbox) {
const {x, y} = bbox;
if (pt.x < x[0] || pt.x > x[1] ||
pt.y < y[0] || pt.y > y[1]) {
return false;
}
}
}
하지만 객체 비구조화를 이용할 때 아래 주의사항을 기억하자.
const { bbox } = polygon;
if (!bbox) {
caculatePolygonBbox(polygon); //polygon.bbox가 채워진다.
}
bbox
와 polygon.bbox
는 다른 값을 참조하게 된다.
function fn(p: Polygon) {/* ... */}
polygon.bbox //타입이 BoundingBox | undefined
if (polygon.bbox) {
polygon.bbox //타입은 BoundingBox
fn(polygon);
}
함수 호출은 polygon.bbox
를 제거할 가능성이 있기 때문에 타입을 원래대로 되돌리는 것이 안전할 수 있다.
타입스크립트는 단순히 타입을 추론할 때 해당 값만을 고려하는 것이 아니라 문맥까지 고려하여 타입을 추론합니다.
type Language = 'JavaScript' | 'TypeScript' | 'Python';
function setLanguage(language: Language) {/*...*/}
setLanguage('JavaScript'); //정상
let language = 'JavaScript';
setLanguage(language); //'string'형식의 인수는 Language 매개변수에 할당될 수 없습니다.
이런 경우에는 language
에 정확히 타입을 명시함으로써 해결할 수 있다. 두 가지 해결법을 확인해보자.
let language: Language = 'JavaScript';
setLanguage(language);//정상
const language = 'JavaScript';
setLanguage(language); //정상
const
를 사용하여 language
는 더이상 변경할 수 없다는 것을 알려주는 것이다. 그래서 더욱 정확한 타입인 문자열 리터럴로 판단되어 타입 체크를 통과한다.
function panTo(where: [number, number]) {/*...*/}
panTo([10, 20]) //정상
const loc = [10, 20];
panTo(loc);
// ~~'number[]' 형식의 인수는 '[number, number]' 형식의 매개변수에 할당될 수 없습니다
문맥과 값을 분리하였기 때문에 오류가 발생하였다. loc
이 number[]
로 추론되었기 때문! 해결해보도록 하자.
const loc: [number, number] = [10, 20];
panTo(loc) //정상
const
는 값이 가리키는 참조가 변하지 않는 얕은 상수이다. 그러나 as const
는 그 값이 내부까지 상수라는 사실을 타입스크립트에게 안내한다.
const loc = [10, 20] as const;
panTo(loc);
그런데 타입은 number[]
가 아니라, readonly [10, 20]
로 추론된다. 너무 과하게추론된 것을 알 수 있다.
오류를 고칠 수 있는 최선의 방법은 panTo
함수에 readonly
구문을 추가하는 것이다.
function panTo(where: readonly [number, number]) {/*...*/}
const loc = [10, 20] as const;
panTo(loc);
그러나 as const
는 타입 정의에 실수가 있었을 때 그곳에서 오류가 발생하지 않고, 호출되는 곳에서 오류가 발생한다. 그렇기 때문에 여러 번 중첩된 객체에서 오류가 발생한다면 근본적인 원인을 파악하기 힘들어진다.
객체 사용 시에도 주의해야 한다.
type Language = 'JavaScript' | 'TypeScript' | 'Python';
interface GovernedLanguage {
language: Language;
organization: string;
}
function complain(language: GovernedLanguage) {/*...*/}
complain({language: 'TypeScript', organization: 'Microsoft' });
const ts = {
language: 'TypeScript',
organization: 'Microsoft',
}
complain(ts);
여기서 language
의 타입은 string
으로 추론된다. 그래서 타입 선언을 따로 추가해주거나, 상수 단언(as const)를 이용하여 해결해줄 수 있다.
const ts = {
language: 'TypeScript',
organization: 'Microsoft',
} as const