이번 글에서는 Typescript의 타입 확장과 타입 좁히기에 대해 정리해보았습니다.
1. 타입 확장하기
타입 확장은 기존 타입을 사용해 새로운 타입을 정의하는 것을 의미합니다.
타입 확장의 가장 큰 장점은 기존 타입을 바탕으로 타입을 확장함으로서 불필요한 코드 중복을 줄일 수 있다는 점입니다.
interface와 type 키워드로 정의된 타입은 extends, 교차 타입, 유니온 타입을 사용해 타입을 확장합니다. extends, 교차 타입, 유니온 타입 간의 차이를 파악하고 언제 사용하면 좋은지 정리해보았습니다.
1) extends
extends를 활용하면 기존 타입을 확장해 새로운 타입을 정의할 수 있습니다.
아래 예시 코드를 살펴보겠습니다.
예시 코드
// 기본 장바구니 아이템 타입
interface BaseCartItem {
id: string; // 상품 고유 ID
name: string; // 상품명
price: number; // 상품 가격
}
// BaseCartItem을 확장한 CartItem 타입
interface CartItem extends BaseCartItem {
quantity: number; // 👉 수량 정보 추가
options?: string[]; // 👉 옵션 정보 (선택사항)
discountRate?: number; // 👉 할인율 (선택사항)
}
// 사용 예시
const myItem: CartItem = {
id: "item-1",
name: "티셔츠",
price: 20000,
quantity: 2,
options: ["색상: 블랙", "사이즈: L"]
}; // ✅ BaseCartItem의 모든 속성 + CartItem의 추가 속성 포함
2) 유니온 타입
2개 이상의 타입을 조합하여 사용하는 방법 입니다. 집합 관점에서 합집합으로 해석할 수 있습니다.
type MyUnion = A | B;
위 의미를 해석하면, MyUnion은 타입 A와 B의 합집합으로, A의 모든 원소는 집합 MyUnion의 원소이며, B의 모든 원소 역시 MyUnion의 원소로, A와 B타입의 모든 값이 MyUnion타입의 값이 됩니다.
단, 주의해야할 점은 유니온 타입으로 선언된 값은 유니온 타입에 포함된 모든 타입이 공통으로 갖고 있는 속성에만 접근할 수 있습니다. 타입 스크립트의 타입을 속성의 집합이 아니라 값이 집합이라고 생각해야 좀 더 이해하기 쉽습니다.
// 요리 단계 타입
interface CookingStep {
orderId: string; // 주문 ID
time: number; // 요리 시간(분)
price: number; // 요리 가격
chef: string; // 요리사 이름
}
// 배달 단계 타입
interface DeliveryStep {
orderId: string; // 주문 ID
time: number; // 배달 소요 시간(분)
distance: number; // 배달 거리(km)
deliveryPerson: string; // 배달원 이름
}
// 유니온 타입 정의 - CookingStep 또는 DeliveryStep 중 하나
type Step = CookingStep | DeliveryStep;
// 사용 예시
function processStep(step: Step) {
// 🔍 공통 속성에만 직접 접근 가능
console.log(`주문번호: ${step.orderId}, 소요시간: ${step.time}`);
// 🚫 Error: chef 속성은 CookingStep에만 있어 직접 접근 불가
// console.log(step.chef);
// ✅ 타입 가드를 사용하여 특정 타입 속성에 접근
if ('chef' in step) {
// 👨🍳 CookingStep인 경우
console.log(`요리사: ${step.chef}, 가격: ${step.price}`);
} else {
// 🚚 DeliveryStep인 경우
console.log(`배달원: ${step.deliveryPerson}, 거리: ${step.distance}km`);
}
}
즉, step이라는 유니온 타입은 CookingStep 또는 DeliveryStep 타입에 해당할 뿐이지만 CookingStep이면서 DeleiveryStep인 것은 아니다.
3) 교차 타입
유니온 타입과 유사하게 기존 타입을 합쳐 필요한 모든 기능을 가진 하나의 타입으로 만드는 방법이지만, 다른 점이 있습니다.
// 요리 단계 타입
interface CookingStep {
orderId: string; // 주문 ID
time: number; // 요리 시간(분)
price: number; // 요리 가격
}
// 배달 단계 타입
interface DeliveryStep {
orderId: string; // 주문 ID
time: number; // 배달 소요 시간(분)
distance: number; // 배달 거리(km)
}
// 교차 타입 정의 - CookingStep과 DeliveryStep의 모든 속성 포함
type BaedalProgress = CookingStep & DeliveryStep;
// 사용 예시
const order: BaedalProgress = {
orderId: "order-123",
time: 60, // ⏱️ 총 소요 시간(분)
price: 15000, // 💰 요리 가격
distance: 3.2 // 🛣️ 배달 거리(km)
};
// ✅ BaedalProgress는 CookingStep과 DeliveryStep의 모든 속성을 가집니다
console.log(`주문번호: ${order.orderId}`);
console.log(`소요시간: ${order.time}`);
console.log(`가격: ${order.price}`);
console.log(`거리: ${order.distance}`);
BaedalProgress는 CookingStep과 DeliveryStep 타입을 합쳐 모든 속성을 가진 단일 타입이 됩니다.
type MyIntersection = A & B;
유니온은 합집합의 개념이라고 했지만, 교차 타입은 교집합의 개념과 비슷합니다.
MyIntersection 타입의 모든 값은 A 타입의 값이며, 동시에 B타입의 값입니다.
집합 관점에서 해석하면 집합 MyIntersection 의 모든 원사는 집합 A의 원소이자 B의 원소입니다.
( BeadalProgress 교차 타입은 CookingStep이 가진 속성(orderId, time, price)과 DeliveryStep이 가진 속성(orderId, time, distance)를 모두 만족하는 값의 집합이라고 해석할 수 있습니다.)
두 타입에 공통된 속성이 없더라도, 두 타입의 속성을 모두 포함한 타입이 됩니다. 타입 자체가 속성이 아닌 값의 집합으로 해석되기 때문입니다.
반면 교차 타입 사용 시, 타입이 서로 호환되지 않는 경우도 있습니다.
// 문자열 ID 타입
type IdType = {
id: string; // 문자열 타입의 id
};
// 숫자 타입
type Numeric = {
id: number; // 숫자 타입의 id
};
// 호환되지 않는 교차 타입 - id가 string과 number를 동시에 만족할 수 없음
type Universal = IdType & Numeric;
// ⚠️ 시도하면 에러 발생
const value: Universal = {
id: "abc123" // 🚫 에러! string과 number 타입을 동시에 만족할 수 없음
};
// 💡 Universal 타입의 id는 사실상 never 타입이 됨
// string과 number의 교집합은 없기 때문에 어떤 값도 할당 불가
Universal은 IdType과 Numeric의 교차타입이므로, 두 타입을 모두 만족하는 경우에만 만족기 때문에 Universal의 타입은 never가 됩니다.
4) extends와 교차 타입
extends 키워드를 사용해 교차 타입을 작성할 수도 있습니다.
교차 타입을 사용할 경우 각각의 타입은 type 키워드로 선언되어야 합니다. 왜냐하면, 유니온 타입과 교차 타입을 사용한 새로운 타입은 오직 type 키워드로만 선언할 수 있기 때문입니다.
여기서 주의할 점은 extends 키워드를 사용한 타입이 교차 타입과 100% 상응하지는 않는 다는 것입니다.
아래 코드 예시를 통해 살펴보겠습니다.
// DeliveryTip 타입 정의
interface DeliveryTip {
tip: number; // 💰 배달팁은 숫자 타입
}
// ❌ Error: 같은 이름의 속성 tip이 서로 호환되지 않음
interface Filter extends DeliveryTip {
tip: string; // 🚫 number와 호환되지 않는 string 타입으로 재정의 시도
}
DeliveryTip 타입은 number 타입의 tip 속성을 가지고 있고, DeliveryTip을 extends로 확장한 Filter 타입에 string 타입의 속성 tip을 선언하면 타입이 호환되지 않는다는 에러가 발생합니다.
그렇담 아래와 코드를 작성했을 땐 어떻게 될까요?
// DeliveryTip 타입 정의
interface DeliveryTip {
tip: number; // 💰 배달팁은 숫자 타입
}
// ✅ 컴파일 에러 없음 (하지만 tip의 타입은 never가 됨)
type Filter = DeliveryTip & {
tip: string; // 🤔 number와 string은 호환되지 않지만 컴파일 시점에는 에러 없음
};
// 실제 사용 시에는 never 타입이 되어 어떤 값도 할당 불가
const myFilter: Filter = {
tip: "무료" // 🚫 실제로는 에러! tip은 never 타입이 됨
};
// 💡 교차 타입에서는 타입 선언 시점에 에러를 발생시키지 않고
// 사용 시점에 해당 속성을 never 타입으로 만들어 어떤 값도 할당할 수 없게 함
extends를 &로만 바꿨을 뿐인데 에러가 발생하지 않습니다.
이 경우 tip 속성의 타입은 number도 아니고 string 도 아닌 never가 됩니다.
type 키워드는 교차 타입으로 선언 시, 새롭게 추가되는 속성에 대해 미리 알 수 없기 때문에 선언 시 에러가 발생하지 않습니다. 하지만, tip 이라는 같은 속성에 대해 서로 호환되지 않는 타입이 선언되면서 never가 된 것 입니다.
2. 타입 좁히기
타입 좁히기란 변수 또는 표현식의 타입 범위를 더 작은 범위로 좁혀나가는 과정을 의미합니다. 타입 좁히기를 통해 더 정확하고 명시적인 타입 추론이 가능하고, 복잡한 타입을 작은 범위로 축소해 타입 안정성을 높일 수 있습니다.
1) 타입 가드
타입스크립트에서의 분기 처리는 조건과 타입 가드를 활용해 변수나 표현식의 타입 범위를 좁혀 다양한 상황에 따라 다른 동작을 수행하는 것을 의미합니다.
타입 가드는 런타임에 조건문을 사용해 타입을 검사하고, 타입 범위를 좁혀줍니다.
여러 타입을 할당할 수 있는 스코프에서 특정 타입을 조건으로 만들어 분기 처리하고 싶을 때, 여러 타입을 할당할 수 있다는 것은 변수가 유니온 또는 any 타입 등과 같이 조건으로 검사하려는 타입보다 넓은 범위의 타입을 가진다는 것을 의미합니다.
※ 스코프(scope)란? 타입스크립트에서의 스코프는 변수와 함수 등의 식별자가 유효한 범위를 나타냅니다. 즉, 변수와 함수를 선언하거나 사용할 수 있는 영역을 의미합니다.
특정 인자의 타입이 A 또는 B일 때를 구분해 로직을 처리한다면, if 문을 사용하면 될 것 같지만 컴파일 시 타입 정보가 모두 제거되 런타임에는 존재하지 않기 때문에 이는 불가한 방법입니다.
(0) 타입 가드의 구분
따라서, 특정 문맥 안에서 타입 스크립트가 해당 변수를 타입 A로 추론하도록 유도하면서 런타임에도 유효하도록 하는 방법이 필요한데, 이 때 사용하는 방법이 타입 가드 입니다. 타입 가드에는 크게 자바스크립트 연산자를 사용한 타입 가드와 사용자 정의 타입 가드로 구분할 수 있습니다.
(1) 자바스크립트 연산자를 사용한 타입 가드
자바스크립트 연산자를 사용하는 이유는 런타임에 유효한 타입 가드를 만들기 위해서 입니다.
typeof, instanceof, in과 같은 연산자 사용하며, 제어문으로 특정 타입 값을 가질 수 밖에 없는 상황을 유도해 타입을 좁히는 방식 입니다.
(2) 사용자 정의 타입 가드
사용자가 직접 어떤 타입으로 값을 좁힐지 직접 지정하는 방식 입니다.
(1) 원시 타입 추론 할 땐 typeof 활용
typeof 연산자는 이용하면 원시 타입에 대해 추론할 수 있습니다. 하지만, 자바스크립트 타입 시스템에만 대응할 수 있으며, 자바스크립트 동작 방식으로 인해 null과 배열 타입이 object 타입으로 판별되는 등 복잡한 타입 검증에는 한계가 있으므로 원시 타입을 좁히는 용도로만 사용하는 것이 권장됩니다.
typeof 연산자를 사용해 검사할 수 있는 타입 목록
- "string": 문자열 타입 (예: typeof "hello" === "string")
- "number": 숫자 타입 (예: typeof 42 === "number")
- "boolean": 불리언 타입 (예: typeof true === "boolean")
- "undefined": undefined 타입 (예: typeof undefined === "undefined")
- "object": 객체 타입 (null, 배열, 일반 객체 포함) (예: typeof {} === "object")
- "function": 함수 타입 (예: typeof (() => {}) === "function")
- "symbol": 심볼 타입 (예: typeof Symbol() === "symbol")
- "bigint": BigInt 타입 (ES2020 이상) (예: typeof BigInt(123) === "bigint")
(2) 인스턴스화된 객체 타입을 판별할 땐 intanceof 활용
instanceof 연산자는 인스턴스화된 객체 타입을 판별하는 타입 가드로 사용할 수 있습니다. A instanceof B의 형태로 A에는 타입 검사할 대상 변수, B에는 특정 객체의 생성자가 들어갑니다.
A의 프로토타입 체인에 생성자 B가 존재한다면 true, 그렇지 않으면 false를 반환합니다.
아래 예시에서는 HTMLInputElement에 존재하는 blur 메서드를 사용하기 위해서 event.target이 HTMLInputElement의 인스턴스인지 검사한 후 분기처리하는 로직을 구현한 코드입니다.
// 🎯 폼 제출 이벤트 핸들러
function handleSubmit(event: Event) {
event.preventDefault(); // 📝 기본 동작 방지
// 🧪 event.target이 HTMLInputElement인지 확인
if (event.target instanceof HTMLInputElement) {
// ✅ HTMLInputElement로 타입이 좁혀짐
event.target.blur(); // ⚡ input 요소에만 있는 blur 메서드 사용 가능
console.log(event.target.value); // 📋 input 값 접근 가능
}
// ⚠️ 타입 가드 없이 사용하면 컴파일 에러 발생
// event.target.blur(); // 🚫 에러: 'target'의 타입이 'EventTarget'이므로 'blur' 속성이 없음
}
(3) 객체의 속성이 있는지 없는지 구분할 땐 in 활용
in 연산자는 객체에 속성이 있는지 확인한 후 true 또는 false를 반환합니다.
A in B 형태로 사용되는데 A라는 속성이 B 객체에 존재하는지 검사합니다. 프로토타입 체인으로 접근할 수 있는 속성이라면 전부 true를 반환하며, 속성에 undefined가 할당되어도 속성 자체가 있는지 없는지 여부만 판단합니다.(delete 연산을 통해 객체 내부에서 해당 속성을 제거해야 false 반환)
// 👥 사용자 타입 정의
interface User {
id: number;
name: string;
email: string;
}
// 🏢 회사 타입 정의
interface Company {
id: number;
name: string;
address: string;
}
// 🎯 유저 또는 회사 정보를 출력하는 함수
function printInfo(entity: User | Company) {
console.log(`ID: ${entity.id}`);
console.log(`Name: ${entity.name}`);
// 🧪 'email' 속성이 있는지 확인하여 User 타입인지 체크
if ('email' in entity) {
// ✅ User 타입으로 좁혀짐
console.log(`Email: ${entity.email}`);
}
// 🧪 'address' 속성이 있는지 확인하여 Company 타입인지 체크
if ('address' in entity) {
// ✅ Company 타입으로 좁혀짐
console.log(`Address: ${entity.address}`);
}
}
자바스크립트의 in 연산자는 런타임 값만 검사하는 반면, 타입스크립트에서는 객체 타입에 속성이 존재하는지 여부를 검사합니다.
위 코드 처럼 여러 객체 타입을 유티온 타입으로 가질 때 in 연산자를 사용해 속성의 유무에 따라 조건 분기를 할 수 있습니다.
(4) 사용자 정의 타입 가드 만들 땐 is 활용
직접 타입 가드 함수를 만들 땐, 반환 타입이 타입 명제인 함수를 정의해 사용할 수 있습니다.
타입 명제는 A is B 형식으로 작성되는데, A는 매개변수 이름 B는 타입 입니다.
※ 타입 명제 (type predicates) : 함수의 반환 타입에 대한 타입 가드를 수행하기 위해 사용되는 특별한 형태의 함수
// 🎁 응답 데이터 타입 정의
interface SuccessResponse {
status: 'success';
data: any;
}
interface ErrorResponse {
status: 'error';
message: string;
}
// 🎯 API 응답 타입 (성공 또는 에러)
type ApiResponse = SuccessResponse | ErrorResponse;
// 🧪 성공 응답인지 확인하는 사용자 정의 타입 가드
function isSuccessResponse(response: ApiResponse): response is SuccessResponse {
return response.status === 'success';
}
// 🎯 API 응답 처리 함수
function handleResponse(response: ApiResponse) {
// ✅ 사용자 정의 타입 가드로 응답 타입 좁히기
if (isSuccessResponse(response)) {
// 🎉 SuccessResponse 타입으로 좁혀짐
console.log(`성공! 데이터:`, response.data);
} else {
// ❌ ErrorResponse 타입으로 좁혀짐
console.error(`에러 발생: ${response.message}`);
}
}
좀 더 쉽게 이해하기
🍎 과일 분류하기
// 가능한 과일 코드 목록
type FruitCode = "APPLE" | "BANANA" | "ORANGE";
// 과일 코드별 한글 이름
const FruitNameMap = {
"APPLE": "사과",
"BANANA": "바나나",
"ORANGE": "오렌지"
};
// 허용된 과일 코드 목록
const allowedFruitCodes = ["APPLE", "BANANA", "ORANGE"];
1️⃣ is를 사용하지 않는 방식 (문제 발생)
// ❌ boolean 반환 - 타입스크립트는 내부 로직을 이해하지 못함
const isFruitCode = (code: string): boolean => {
return allowedFruitCodes.includes(code);
};
function getFruitNames(codes: string[]): string[] {
const fruitNames: string[] = [];
codes.forEach(code => {
if (isFruitCode(code)) {
// 🚫 에러! 타입스크립트는 여전히 code를 string으로만 인식
// "string" 타입은 "APPLE" | "BANANA" | "ORANGE" 인덱스로 사용할 수 없음
fruitNames.push(FruitNameMap[code]);
}
});
return fruitNames;
}
2️⃣ is를 사용하는 방식 (문제 해결)
// ✅ 타입 가드 함수: 문자열이 FruitCode인지 확인
const isFruitCode = (code: string): code is FruitCode => {
return allowedFruitCodes.includes(code);
};
function getFruitNames(codes: string[]): string[] {
const fruitNames: string[] = [];
codes.forEach(code => {
if (isFruitCode(code)) {
// ✅ 이제 타입스크립트는 code가 FruitCode 타입임을 알게 됨
// if 블록 안에서 code는 "APPLE" | "BANANA" | "ORANGE" 타입으로 좁혀짐
fruitNames.push(FruitNameMap[code]); // 에러 없음!
}
});
return fruitNames;
}
💡 쉽게 풀어서 설명하면
- 타입스크립트의 한계:
- 타입스크립트는 includes() 같은 함수의 내부 로직을 보고 타입을 추론하지 못해요.
- 그냥 true/false를 반환한다는 것만 알 뿐, 그게 타입에 어떤 의미인지 모릅니다.
- is의 역할:
- code is FruitCode는 "이 함수가 true를 반환하면 code는 FruitCode 타입이다"라고 타입스크립트에게 직접 알려주는 것입니다.
- 이것은 마치 "신분증 검사관"이 "이 사람이 성인이 맞습니다"라고 보증해주는 것과 같아요.
- 결과적으로:
- is를 사용하면 if문 안에서 타입스크립트가 변수의 타입을 더 구체적으로 좁힐 수 있게 됩니다.
- 이를 통해 FruitNameMap[code]와 같은 코드가 타입 오류 없이 안전하게 작동합니다.
if (isDestinationCode(str)) {
// ✅ is 덕분에 str은 여기서 DestinationCode 타입으로 좁혀짐
destinationNames.push(DestinationNameSet[str]);
}
is는 "이 조건이 참이면, 이 변수는 이 타입이 확실하다"고 타입스크립트에게 알려주는 특별한 표시라고 생각하시면 됩니다!
2) 식별할 수 있는 유니온
종종 태그된 유니온으로도 불리는 식별할 수 있는 유니온은 타입 좁히기에 널리 사용되는 방식입니다.
왜 필요할까?
아래와 같이 에러 타입을 정의했다고 할 때,
// 🚫 네트워크 에러 타입
interface NetworkError {
errorCode: 500; // 🔢 HTTP 상태 코드
message: string;
timeout: number;
}
// 🚫 인증 에러 타입
interface AuthError {
errorCode: 401; // 🔢 HTTP 상태 코드
message: string;
token: string;
}
// 🚫 권한 에러 타입
interface PermissionError {
errorCode: 403; // 🔢 HTTP 상태 코드
message: string;
resource: string;
}
// 🎯 에러 타입 유니온
type AppError = NetworkError | AuthError | PermissionError;
이 에러 타입의 유니온 타입을 원소로 하는 배열을 정의하면 아래와 같을 것입니다.
// 🧪 에러 배열 선언 (유니온 타입으로 각 에러 타입을 모두 담을 수 있음)
const errorArr: AppError[] = [
{
errorCode: 500,
message: "서버 연결 오류",
timeout: 3000
},
{
errorCode: 401,
message: "인증 토큰이 만료되었습니다",
token: "expired_token_123"
},
{
errorCode: 403,
message: "접근 권한이 없습니다",
resource: "/admin/users"
}
];
유니온 타입의 원소를 같은 배열을 정의함으로써 다양한 여러 객체를 관리할 수 있게 되었습니다.
문제는 여기서 발생합니다. 여기서 각 에러 타입에 해당되는 필드를 여러개 섞어 가지는 객체에 대해서는 타입 에러를 뱉어야 할테지만, 아래 코드를 작성했을 때 자바스크립트는 덕 타이핑 언어이므로 별도의 타입 에러를 뱉지 않게 됩니다.
덕 타이핑 ?
덕 타이핑(Duck Typing)은 프로그래밍에서 객체의 타입을 판단하는 방식 중 하나입니다. 이 개념은 "만약 어떤 새가 오리처럼 걷고, 오리처럼 꽥꽥거리면, 그것은 오리일 것이다"라는 표현에서 유래했습니다.
덕 타이핑의 핵심 원리는 객체가 특정 클래스의 인스턴스인지 확인하는 대신, 객체가 필요한 메소드와 속성을 가지고 있는지만 확인한다는 것입니다. 즉, 객체의 실제 유형보다는 객체의 행동(메소드와 속성)에 초점을 맞춥니다.
// 🚫 덕 타이핑으로 인한 문제 - 타입스크립트는 구조적 타이핑을 따름
const mixedError = {
errorCode: 500,
message: "혼합된 에러",
timeout: 1000, // NetworkError의 속성
token: "abc123", // AuthError의 속성
resource: "/users" // PermissionError의 속성
};
// ⚠️ 모든 필드가 있기 때문에 에러 없이 배열에 추가됨
errorArr.push(mixedError); // 🤔 이게 어떤 에러 타입인지 명확하지 않음
이렇게 되면 앞으로 개발 과정에서 의미를 알 수 없는 무수한 에러 객체가 생겨날 위험성이 커지게 됩니다.
따라서 에러 타입을 구분할 방법이 필요하고, 각 타입이 비슷한 구조를 가지면서 서로 호환되지 않도록 만들어 주기 위해 타입들이 서로 포함관계를 가지지 않도록 정의해야 합니다. 이 때 사용하는 것이 식별할 수 있는 유니온 입니다.
사용해보자
식별할 수 있는 유니온이란 타입 간의 구조 호환을 막고자 타입마다 구분할 수 있는 판별자를 달아 주어 포함관계를 제거하는 것 입니다.
판별자의 개념으로 errorType이라는 필드를 새로 정의해보겠습니다. 각 에러 타입마다 이 필드에 대해 다른 값을 가지도록 하여 판별자를 달아주면 포함관계를 벗어나게 됩니다.
// 🚫 네트워크 에러 타입 (판별자 추가)
interface NetworkError {
errorType: "NetworkError"; // 🏷️ 판별자 필드 추가
errorCode: 500;
message: string;
timeout: number;
}
// 🚫 인증 에러 타입 (판별자 추가)
interface AuthError {
errorType: "AuthError"; // 🏷️ 판별자 필드 추가
errorCode: 401;
message: string;
token: string;
}
// 🚫 권한 에러 타입 (판별자 추가)
interface PermissionError {
errorType: "PermissionError"; // 🏷️ 판별자 필드 추가
errorCode: 403;
message: string;
resource: string;
}
// 🎯 판별자가 있는 에러 타입 유니온
type AppError = NetworkError | AuthError | PermissionError;
// 🧪 판별자가 있는 에러 객체 배열
const errorArr: AppError[] = [
{
errorType: "NetworkError",
errorCode: 500,
message: "서버 연결 오류",
timeout: 3000
},
{
errorType: "AuthError",
errorCode: 401,
message: "인증 토큰이 만료되었습니다",
token: "expired_token_123"
},
{
errorType: "PermissionError",
errorCode: 403,
message: "접근 권한이 없습니다",
resource: "/admin/users"
}
];
// ❌ 이제 혼합된 에러는 컴파일 에러 발생
const mixedError = {
errorType: "NetworkError", // 🏷️ NetworkError로 명시했지만
errorCode: 500,
message: "혼합된 에러",
timeout: 1000,
token: "abc123", // ⚠️ AuthError의 속성인 token이 있어서 타입 에러
resource: "/users" // ⚠️ PermissionError의 속성인 resource가 있어서 타입 에러
};
// 🚫 컴파일 에러: 'NetworkError' 타입에 'token'과 'resource' 속성이 없음
// errorArr.push(mixedError);
Q. 질문 : 판별자로 errorType 말고 errorCode로 지정은 안되는걸까?
A. 판별자로 errorCode도 사용할 수 있지만 아래 사항들을 고려해야 합니다.
- 유닛 타입 조건: errorCode 값이 각 타입마다 고유하고 유닛 타입(리터럴 타입)이어야 제대로 작동합니다.
- 의미적 명확성: errorType은 직관적으로 "이 에러의 종류"를 나타내므로 코드 가독성이 높아집니다.
- 확장성: 향후 같은 errorCode를 가진 다른 종류의 에러가 필요할 수 있어, errorType과 같은 별도 판별자를 두는 것이 더 유연합니다.
식별할 수 있는 유니온의 판별자 선정 시 주의할 점
식별할 수 있는 유니온의 판별자는 유닛 타입으로 선언되어야 정상적으로 동작합니다.
유닛 타입이란 다른 타입으로 더이상 쪼개지지 않고 오직 하나의 정확한 값을 가지는 타입을 의미합니다.
예) null, undefined, 리터럴 타입, true, 1 등(여러 타입을 할당받을 수 있는 void, string, number는 유닛 타입이 아님)
공식 깃 허브의 이슈 탭을 보면, 식별할 수 있는 유니온 판별자로 사용할 수 있는 타입이 아래와 같이 정의되어 있습니다.
1. 리터럴 타입이어야 한다.
2. 판별자로 선정한 값에 적어도 하나 이상의 유닛 타입이 포함되어야 하며, 인스턴스화 할 수 있는 타입은 포함되지 않아야 한다.
// 🎯 유니온 타입 정의
type FormValue =
| { value: 'a'; answer: true }
| { value: string; answer: false }
| { value: number; answer: true };
// 🧪 함수로 타입 좁히기 테스트
function processForm(form: FormValue) {
// 🧪 'value' 속성으로 타입 좁히기 시도
if (form.value === 'a') {
// ✅ 첫 번째 타입으로 좁혀짐
console.log('첫 번째 타입', form.answer); // form.answer는 true
}
// 🧪 'answer' 속성으로 타입 좁히기 시도
if (form.answer === false) {
// ✅ 두 번째 타입으로 좁혀짐
console.log('두 번째 타입', form.value); // form.value는 string
} else {
// ✅ 첫 번째 또는 세 번째 타입으로 좁혀짐 (answer가 true인 것들)
console.log('첫 번째나 세 번째 타입', form.value); // form.value는 'a' | number
}
}
이 코드에서 판별자가 value일 때, 판별자로 선정한 값 중 'a'만 유일하게 유닛 타입이므로, 해당 타입만 좁혀지는 것을 확인 할 수 있습니다. 반면 판별자가 answer일 경우에는 판별자가 모두 유닛 타입이므로 타입이 정상적으로 좁혀집니다.
3. 정확한 타입 분기 유지하기 - Exhaustiveness Checking
Exhaustiveness Checking은 모든 케이스에 대해 철저하게 타입 검사하는 것을 의미하고, 타입 좁히기에 사용되는 패러다임 중 하나 입니다.
모든 케이스에 대해 분기 처리를 해야만 유지보수 측면에서 안전하다고 생각되는 상황이 생길 경우, 이를 이용해 모든 케이스에 대한 타입 검사를 강제할 수 있습니다.
아래 예시 코드로 자세히 살펴보겠습니다.
// 🧮 도형 종류 유니온 타입
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
// 🎯 도형의 넓이를 계산하는 함수
function calculateArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
default:
// ✅ Exhaustiveness Checking
// 모든 케이스를 처리했다면 이 코드는 실행되지 않음
return exhaustiveCheck(shape);
}
}
// 🧪 Exhaustiveness Checking 함수
function exhaustiveCheck(value: never): never {
throw new Error(`예상치 못한 타입: ${JSON.stringify(value)}`);
}
// 💡 만약 새로운 도형 타입이 추가된다면?
// 예: type Shape = ... | { kind: 'square'; side: number }
// 이 경우 calculateArea 함수를 수정하지 않으면 컴파일 에러 발생!
// never 타입에 square 타입이 할당될 수 없기 때문입니다.
이처럼 모든 케이스에 대한 타입 분기 처리를 해주지 않았을 때 컴파일타입 에러가 발생하게 하는 것을 Exhaustiveness Checking이라고 합니다.
exhaustiveCheck 함수는 매개변수로 never 타입을 선언하고 있으므로, 어떤 값도 매개변수로 받을 수 없고 만약 값이 들어온다면 에러를 내뱉습니다.
이 함수를 타입 처리 조건문의 마지막 else 문제 사용하면 앞의 조건 문에서 모든 타입에 대한 분기 처리를 강제할 수 있습니다.