TypeScript의 Union, Intersection, Narrowing
1. 개요
Union과 Intersection은 단순한 interface보다 더 유연하게 데이터를 표현하기 위한 TypeScript의 두 가지 기본 도구입니다. Narrowing은 컴파일러가 코드의 각 분기에서 값의 구체적인 타입을 “이해”하도록 하는 기술로, 수동으로 타입을 강제 변환(as)할 필요가 없게 해줍니다.
이 글은 union/intersection 개념부터 시작해 일반적인 narrowing 기법들(typeof, instanceof, in, discriminated union)을 거쳐 커스텀 type guard 작성까지 다룹니다. 이 글을 읽은 후에는 언제 union을 사용하고 언제 intersection을 사용할지 알 수 있으며, Object is possibly undefined 오류나 코드 곳곳에 퍼진 as any를 피할 수 있습니다.
2. Union type
Union (|)은 값이 여러 타입 중 하나에 속할 수 있음을 의미합니다.
type PaymentMethod = "cash" | "card" | "e-wallet";
function describe(method: PaymentMethod): string {
return `Thanh toán bằng: ${method}`;
}
describe("card"); // OK
// describe("paypal"); // 컴파일 오류: union에 없는 값
PaymentMethod는 3개의 특정 literal 문자열만 허용합니다. 다른 값을 전달하면 TypeScript는 컴파일 시점에 바로 오류를 표시하며, 런타임까지 기다릴 필요가 없습니다.
Union은 객체 타입에도 적용할 수 있습니다:
interface CashPayment {
type: "cash";
amount: number;
}
interface CardPayment {
type: "card";
amount: number;
cardNumber: string;
}
type Payment = CashPayment | CardPayment;
이제 Payment 타입의 변수는 CashPayment 또는 CardPayment일 수 있지만, TypeScript는 narrowing을 하지 않는 한 두 타입의 공통 필드(이 경우 type과 amount)에만 접근을 허용합니다.
function printAmount(p: Payment) {
console.log(p.amount); // OK, 공통 필드
// console.log(p.cardNumber); // 오류: CashPayment에는 없는 필드
}
참고: Union은 필드를 합치지 않습니다. 특정 필드에 접근하려면 먼저 narrowing을 해야 합니다(섹션 4 참조).
3. Intersection type
Intersection (&)은 값이 결합된 모든 타입을 동시에 만족해야 함을 의미합니다.
interface Identifiable {
id: string;
}
interface Timestamped {
createdAt: Date;
}
type Order = Identifiable & Timestamped & {
customerName: string;
total: number;
};
const order: Order = {
id: "ORD-001",
createdAt: new Date(),
customerName: "Nguyễn Văn A",
total: 150_000,
};
Order은 Identifiable, Timestamped의 모든 필드와 추가 object literal의 필드를 가져야 합니다. 어떤 필드라도 빠지면 컴파일러가 오류를 보고합니다.
Intersection은 여러 작은 mixin을 하나의 큰 타입으로 “조합”하고 싶을 때 유용하며, 이는 OOP의 composition과 유사하지만 타입 수준에서 작동합니다.
type AuditInfo = { updatedBy: string };
type AuditedOrder = Order & AuditInfo;
const audited: AuditedOrder = {
...order,
updatedBy: "admin",
};
3.1. 충돌하는 원시 타입의 Intersection
Intersection 내의 두 타입이 원시 타입에서 충돌하면 결과는 never입니다:
type Impossible = string & number; // never 타입, 만족하는 값이 없음
이 경우는 보통 | 대신 &를 잘못 입력했다는 신호입니다.
4. Narrowing: 각 코드 분기에서 타입 좁히기
Narrowing은 코드의 조건 검사(if, switch, typeof…)에 기초하여 컴파일러가 더 구체적인 타입을 자동으로 추론하는 과정입니다.
4.1. typeof를 이용한 Narrowing
원시 타입들의 union에 사용됩니다.
function formatId(id: string | number): string {
if (typeof id === "number") {
return id.toFixed(0); // id가 number로 좁혀짐
}
return id.toUpperCase(); // id가 string으로 좁혀짐
}
if 분기에서 TypeScript는 id가 number임을 알므로 .toFixed()를 호출할 수 있습니다. else 분기(암묵적)에서 id는 string만 남게 됩니다.
4.2. instanceof를 이용한 Narrowing
클래스로 이루어진 union에 사용됩니다.
class ValidationError extends Error {
constructor(public field: string) {
super(`Field ${field} is invalid`);
}
}
function handleError(err: Error | ValidationError): void {
if (err instanceof ValidationError) {
console.log(`필드 검증 오류: ${err.field}`);
} else {
console.log(`다른 오류: ${err.message}`);
}
}
err.field는 ValidationError에만 존재하므로, 컴파일러는 instanceof 검사를 한 분기에서만 접근을 허용합니다.
4.3. in을 이용한 Narrowing
union의 타입들이 공유 discriminant 필드가 없이 고유 필드로 구분될 때 사용됩니다.
interface Dog {
bark: () => void;
}
interface Cat {
meow: () => void;
}
function makeSound(pet: Dog | Cat): void {
if ("bark" in pet) {
pet.bark(); // Dog으로 좁혀짐
} else {
pet.meow(); // Cat으로 좁혀짐
}
}
in 연산자는 런타임에 object에 property가 존재하는지 확인하며, 컴파일러는 그 결과를 이용해 타입을 좁힙니다.
4.4. Discriminated Union — 가장 안전한 방법
이는 실제 코드에서 가장 일반적인 패턴입니다: union의 각 “분기”를 구분하기 위해 공유 literal 필드(보통 kind 또는 type이라 부름)를 추가합니다.
interface SuccessResult {
status: "success";
data: string;
}
interface ErrorResult {
status: "error";
message: string;
}
type ApiResult = SuccessResult | ErrorResult;
function handleResult(result: ApiResult): void {
switch (result.status) {
case "success":
console.log(`데이터: ${result.data}`);
break;
case "error":
console.log(`오류: ${result.message}`);
break;
}
}
컴파일러는 status 필드를 “discriminant”로 사용합니다: case "success"에서 result는 SuccessResult로 좁혀지고, case "error"에서 ErrorResult로 좁혀집니다. TypeScript는 2개보다 복잡한 union에서 이 방법을 권장합니다. 왜냐하면 switch는 exhaustiveness 검사를 지원하기 때문입니다(섹션 4.6).
4.5. Truthiness와 optional chaining을 이용한 Narrowing
interface Customer {
name: string;
phone?: string;
}
function callCustomer(customer: Customer): void {
if (customer.phone) {
// customer.phone이 string | undefined에서 string으로 좁혀짐
console.log(`전화: ${customer.phone}`);
} else {
console.log("고객이 전화번호를 아직 입력하지 않음");
}
}
Truthy 검사(if (customer.phone))는 undefined(그리고 빈 문자열, 하지만 전화번호의 경우 중요하지 않음)을 제외합니다.
4.6. never를 이용한 Exhaustiveness 검사
Union에 새로운 분기를 추가했는데 처리를 빠뜨렸을 때, 런타임 버그를 몰래 발생시키지 않도록 컴파일러에게 즉시 오류를 보고하게 할 수 있습니다. default 분기에서 never 타입을 사용하세요:
function assertUnreachable(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function handleResultSafe(result: ApiResult): string {
switch (result.status) {
case "success":
return result.data;
case "error":
return result.message;
default:
return assertUnreachable(result); // 새 분기를 추가했는데 처리를 빠뜨리면 여기서 컴파일 오류 발생
}
}
나중에 누군가 ApiResult에 PendingResult를 추가했는데 해당 case를 빠뜨리면, default 분기의 result는 더 이상 never가 아니게 되고, 컴파일러는 빌드 시점에 바로 오류를 보고합니다. 프로덕션에서 버그가 나타날 때까지 기다릴 필요가 없습니다.
5. User-defined type guard
검사 로직이 간단한 식보다 복잡할 때, x is T 타입을 반환하는 함수를 작성하여 narrowing을 재사용할 수 있습니다.
interface PaidOrder {
status: "paid";
paidAt: Date;
}
interface PendingOrder {
status: "pending";
}
type OrderState = PaidOrder | PendingOrder;
function isPaid(order: OrderState): order is PaidOrder {
return order.status === "paid";
}
function printReceipt(order: OrderState): void {
if (isPaid(order)) {
console.log(`결제 완료: ${order.paidAt.toISOString()}`);
} else {
console.log("아직 결제 안 됨");
}
}
order is PaidOrder는 type predicate입니다. 컴파일러는 isPaid 함수의 결과를 신뢰하여 if 분기에서 order를 PaidOrder로 좁힙니다. 검사 로직이 별도의 함수에 있어도 마찬가지입니다.
6. 테스트를 통한 검증
Narrowing은 컴파일 타임 기능이므로 “테스트”는 실제로 tsc를 실행하여 오류가 없는지 확인하는 것입니다. 하지만 해당 런타임 동작에 대한 테스트도 작성해야 합니다.
npm install -D vitest typescript
import { describe, it, expect } from "vitest";
function totalAmount(payment: CashPayment | CardPayment): number {
return payment.amount;
}
describe("Payment narrowing", () => {
it("현금 결제의 amount를 정확히 계산", () => {
const cash: CashPayment = { type: "cash", amount: 50_000 };
expect(totalAmount(cash)).toBe(50_000);
});
it("카드 결제의 amount를 정확히 계산", () => {
const card: CardPayment = {
type: "card",
amount: 200_000,
cardNumber: "4111111111111111",
};
expect(totalAmount(card)).toBe(200_000);
});
});
npx vitest run을 실행하면 다음과 같은 결과를 얻습니다:
✓ Payment narrowing > 현금 결제의 amount를 정확히 계산
✓ Payment narrowing > 카드 결제의 amount를 정확히 계산
Test Files 1 passed (1)
Tests 2 passed (2)
7. 흔한 오류와 사용하면 안 되는 경우
- Narrowing을 피하기 위해
as남용: 계속 타입을 강제 변환(as SomeType)하여 컴파일러를 “만족”시키려면, union/type guard 설계가 잘못되었을 가능성이 높습니다. Discriminated union으로 돌아가세요. - 같은 이름이지만 다른 타입의 필드를 가진 두 interface의 Intersection: TypeScript는 합치려고 시도하지만, 타입이 충돌하면(예: 한쪽은
string, 다른 쪽은number) 그 필드는never가 됩니다. 이는 이해하기 어려운 오류를 야기합니다. Mixin을 설계할 때 항상 필드명을 통일하세요. - 너무 넓은 Union (예:
string | number | boolean | object): 이를 사용하는 모든 곳에서 narrowing을 해야 하므로 코드가 복잡해집니다. Union에 4-5개 이상의 무관한 분기가 있으면 domain model을 다시 검토하세요. 여러 함수/overload로 나누는 것이 나을 수 있습니다. switch에서never를 포함한default빠뜨리기: exhaustiveness 검사의 이점을 잃으므로 union을 확장할 때 분기를 빠뜨리기 쉽습니다.- 복잡한 prototype chain을 가진 object에서
inoperator 사용:in은 상속된 property도 확인하므로, 깊은 inheritance를 가진 클래스에서 예상과 다른 narrowing 결과를 줄 수 있습니다. 이 경우instanceof나 discriminant 필드가 더 신뢰할 수 있습니다.
참고: Narrowing은 TypeScript가 정적 제어 흐름으로 증명할 수 있을 때만 제대로 작동합니다. 좁혀진 값을 비동기 콜백 함수(
setTimeout, promise.then)를 통해 다른 변수에 할당하면, 좁혀진 타입이 “잊혀질” 수 있습니다. TypeScript는 모든 경우에 closure 경계를 절대적으로 추적하지 않습니다.
8. 결론
Union은 “여러 타입 중 하나”를, Intersection은 “모든 타입을 동시에” 나타냅니다. 이 두 도구는 TypeScript에서 정확한 domain model을 구축하기 위한 기초입니다. Narrowing(typeof, instanceof, in, discriminated union, 커스텀 type guard)은 수동 타입 강제 변환 없이 안전하게 union을 활용하도록 해줍니다.
실전 규칙: 2개 이상의 분기가 있을 때는 discriminated union을 우선하고, switch에는 항상 never를 이용한 exhaustiveness 검사를 추가하며, 같은 이름이지만 다른 타입의 필드를 가진 type들의 intersection을 피하세요. 올바르게 적용하면 코드를 작성할 때 많은 로직 오류를 즉시 잡을 수 있으며, 프로덕션까지 기다릴 필요가 없습니다.