-
이펙티브 타입스크립트- 3장 타입추론 (3)TypeScript 2022. 4. 17. 17:17
아이템 22. 타입 좁히기
타입 좁히기는 타입 넓히기의 반대로 타입스크립트가 넓은 타입으로부터 좁은 타임으로 진행하는 과정이다.
// 1. 가장 일반적인 예시인 null check const el = document.getElementById('foo'); // 타입이 HTMLElement | null if (el) { el // 타입이 HTMLElement el.innerHTML = 'Party Time'.blink(); } else { el // 타입이 null alert('No element #foo'); } // 2. 예외 던져서 타입 좁히기 const el = document.getElementById('foo'); // 타입이 HTMLElement | null if (!el) throw new Error('Unable to find #foo'); el; // 이제 타입은 HTMLElement el.innerHTML = 'Party Time'.blink(); // 3. instanceof로 타입 좁히기 function contains(text: string, search: string|RegExp) { if (search instanceof RegExp) { search // 타입이 RegExp return !!search.exec(text); } search // 타입이 string return text.includes(search); } // 4. 속성 체크로 타입 좁히기 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 } // 5. 내장 함수로 타입 좁히기(Array.isArray) function contains(text: string, terms: string|string[]) { const termList = Array.isArray(terms) ? terms : [terms]; termList // 타입이 string[] // ... } // 6. 명시적인 '태그' 로 타입 좁히기 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)이라 불린다. // 7. 사용자 정의 타입 가드 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; }
사용자 정의 타입 가드에서, 반환 타입의 'el is HTMLInputElement'는 함수의 반환값이 true인 경우 타입 체커에게 매개변수의 타입을 좁힐 수 있다고 알려준다.
타입 가드를 활용하면 배열과 객체의 타입 좁히기를 도울 수 있다.
const jackson5 = ['Jackie', 'Tito', 'Jermaine', 'Marlon', 'Michael']; const members = ['Janet', 'Michael'] .map(who => jackson5.find(n => n === who)); // 타입이 (string | undefined)[] // filter를 사용해도 undefined가 걸러지지 않음 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[]
보충
타입가드
function doSomething(arg: string | number)
위 예에서 인자는 string일 수도 있고 number일 수도 있다. 이러한 유니온 타입의 인자를 처리할 때 정확히 어떤 타입인지 검사를 수행하는 표현식을 타입가드라고 한다.
원시타입에 식별하기
원시 타입은 typeof 연산자를 이용해 타입을 검사할 수 있다.
const doSomething(id: string | number) { if (typeof id === "string") { id.trim() // id가 문자열 이기 trim()을 사용해도 됨 } else { id // string 타입이 아니기 때문에 남은 number 타입임 } }
위 예에서 타입 가드 없이 id.trim()을 호출하면 타입스크립트는 오류를 내뱉는다. 왜냐면 number 타입일 가능성도 있기 때문이다.
클래스 객체 식별하기
생성자 함수가 반환되는 클래스 객체는 typeof로 검사할 수 없다. 모든 객체가 문자열 'object' 반환하기 때문이다.
대신 instanceof 연산자를 이용해 식별한다.
class Diner { } class Merchant { } const doSomething(user: Diner | Merchant) { if (user instanceof Diner) { user.createOrder() // user가 Diner 클래스의 객체임 } else { user.acceptOrder() // user가 Merchant 클래스의 객체임 } }
일반 객체 식별하기
타입스크립트로 객체 타입을 지정할 때 클래스 말고도 인터페이스를 사용한다.
Bird나 Fish 타입을 인자로 받는 아래 함수의 본체는 이를 식별하기위해 특정 속성 유무를 사용한다.
interface Bird { fly(): number } interface Fish { swim(): number } function doSomething(animal: Fish | Bird) { if ("swim" in animal) { ;(animal as Fish).swim() // swim 속성이 있으니깐 Fish 타입이구나 } else { ;(animal as Bird).fly() // fly 속성이 있으니깐 Bird 타입이구나 } }
문제는 if("swim" in animial)에서 Fish 타입임을 체크했는데 그 다음 블록에서 Swim as Fish로 다시 한번 Fish임을 알려줘야 한다는 점이다. 이는 코드를 장황하게 만든다. 이때 사용자 정의 타입가드를 사용하기 좋다
사용자 정의 타입가드
타임을 검사하는 함수를 만들고 이 함수에 전달한 인자가 특정 타입임을 확인하는 메세지를 반환한다. 메세지라고 표현했는데 문서에서는 타입 술어(Type Predicate 라고 했는데 번역이 마땅찮다)라고 부른다. Fish 타입을 검사하는 isFish() 함수는 animal is Fish 라는 타입 술어를 반환한다.
function isFish(animal: Fish | Bird): animal is Fish { return (animal as Fish).swim !== undefined }
이제 isFish를 조건문에서 사용하면 유니온 타입에서 특정 타입을 검사하는 코드가 단순해진다.
function doSomething(animal: Fish | Bird) { if (isFish(animal) { animal.swim() // animal은 Fish 타입이 확실함 } else { animal.fly() // animal은 Fish 타입이 아니니깐 Bird 타입이 확실함 } }
참고자료: https://jeonghwan-kim.github.io/dev/2021/03/18/type-guard.html
타입스크립트는 조건문에서 타입을 좁히는 데 능숙하지만, 사람에 의한 실수가 발생할 수 있으므로 함상 꼼꼼히 따져봐야 한다.
// 1. typeof null이 'object'임을 간과한 예시 const el = document.getElementById('foo'); // 타입이 HTMLElement | null if (typeof el === 'object') { el; // 타입이 HTMLElement | null } // 2. ''과 0이 falsy 값임을 간과한 예시 function foo(x?: number|string|null) { if (!x) { x; // 타입이 string | number | null | undefined } }
요약
- 분기문 외에도 타입스크립트가 타입을 좁히는 과정을 이해해야 한다.
- 태그된/구별된 유니온과 사용자 정의 타입 가드를 사용하여 타입 좁히기 과정을 원활하게 만들 수 있다.
아이템 23. 한꺼번에 객체 생성하기
객체를 생성할 때는 속성을 하나씩 추가하기 보다는 여러 속성을 포함해서 한꺼번에 생성해야 타입 추론에 유리하다.
// X const pt = {}; pt.x = 3; // '{}' 형식에 'x' 속성이 없습니다. pt.y = 4; // '{}' 형식에 'y' 속성이 없습니다. // 첫 번째 줄의 pt 타입은 {}값을 기준으로 추론되기 때문이다. // X interface Point { x: number; y: number; } const pt: Point = {}; // '{}' 형식에 'Point' 형식의 x, y 속성이 없습니다. pt.x = 3; pt.y = 4; // O const pt = { x: 3, y: 4, }; // 정상 이렇게 객체를 한번에 정의하면 해결할 수 있다.
객체를 반드시 제각각 나눠서 만들어야 한다면, 타입 단언문(as)을 사용해 타입 체커를 통과하게 할 수 있다.
// O const pt = {} as Point; pt.x = 3; pt.y = 4; 물론 이 경우에도 선언할 때 객체를 한꺼번에 만드는 게 더 낫다(아이템9) // O const pt: Point = { x: 3, y: 4, };
'객체 전개 연산자(...)'를 사용하면 큰 객체를 한꺼번에 만들어 낼 수 있다.
// X const pt = { x: 3, y: 4 }; const id = { name: 'Pythagoras' }; const namedPoint = {}; Object.assign(namedPoint, pt, id); namedPoint.name; // '{}' 형식에 'name' 속성이 없습니다. // O const namedPoint = { ...pt, ...id }; namedPoint.name; // 타입이 string // 객체 전개 연산자를 활용하면 필드 단위의 객체 생성도 가능 // (대신, 모든 업데이트마다 새 변수를 사용해야 함) const pt0 = {}; const pt1 = { ...pt0, x: 3 }; const pt: Point = { ...pt1, y: 4 };
타입에 안전한 방식으로 조건부 속성을 추가하려면, 속성을 추가하지 않는 null 또는 {}으로 객체 전개를 사용하면 된다.
declare let hasMiddle: boolean; const firstLast = { first: 'Harry', last: 'Truman' }; const president = { ...firstLast, ...(hasMiddle ? { middle: 'S' } : {})}; // president의 타입은 다음과 같이 추론된다. const president: { middle?: string; first: string; last: string; }
만약 여러 개의 속성을 조건부로 추가하면, 유니온 타입으로 추론된다.
declare let hasDates: boolean; const nameTitle = { name: 'Khufu', title: 'Pharaoh' }; const pharaoh = { ...nameTitle, ...(hasDates ? { start: -2589, end: -2566 } : {}) }; // pharaoh의 타입 추론 const pharaoh: { start: number; end: number; name: string; title: string; } | { name: string; title: string; } pharaoh.start; // '{ name: string; title: string; }' 형식에 'start' 속성이 없습니다.
이 경우에는 start와 end가 항상 함께 정의되기 때문에 유니온으로 사용되는 게 가능한 값의 집합을 표현하는데 더 정확하다.
만약 선택적 필드로 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 ); pharaoh.start; // 타입이 number | undefined
요약
- 속성을 제각각 추가하지 말고 한꺼번에 객체로 만들어야 한다. 안전한 타입으로 속성을 추가하려면 객체 전개({...a, ...b})를 사용하면 된다.
- 객체에 조건부로 속성을 추가하는 방법을 익히자.
'TypeScript' 카테고리의 다른 글
이펙티브 타입스크립트- 3장 타입추론 (5) (0) 2022.04.23 이펙티브 타입스크립트- 3장 타입추론 (4) (0) 2022.04.18 이펙티브 타입스크립트- 3장 타입추론 (2) (0) 2022.04.10 이펙티브 타입스크립트- 3장 타입추론 (1) (0) 2022.04.03 이펙티브 타입스크립트- 2장 타입시스템 (4) (0) 2022.04.02