typeof만으로 부족할 때, TypeScript 객체 타입을 어떻게 믿을 수 있을까?
개요
typeof의 한계와 새로운 질문.
지난 글에서는 any의 위험성을 인지하고, 그 대안으로 타입 안정성을 보장하는 unknown을 사용하는 여정을 시작했다. unknown은 타입을 먼저 검사하도록 강제하여 런타임 에러를 줄여줄 수 있다.
이제 API 응답으로 unknown 타입의 데이터를 받았다고 상상해보자. typeof를 사용하면 string이나 number 같은 원시 타입은 쉽게 확인할 수 있다.
function process(response: unknown) {
if (typeof response === 'string') {
// 이 블록 안에서 response는 'string' 타입으로 추론된다.
console.log(response.toUpperCase());
}
}
하지만 우리가 원하는 데이터가 아래와 같은 Product 객체라면 어떨까?
interface Product {
id: number;
name: string;
inStock: boolean;
}
typeof response === 'object'라는 조건만으로는 충분하지 않다. null도 'object'를 반환하고, 배열([])도 'object'이며, 우리가 원하는 id, name, inStock 속성을 가졌는지는 전혀 알 수 없기 때문이다.
결국 우리는 다음과 같이 복잡하고 긴 조건문을 작성하게 된다.
function processProduct(response: unknown) {
if (
response &&
typeof response === 'object' &&
'id' in response &&
'name' in response &&
'inStock' in response
) {
// 하지만... 이 블록 안에서도 response의 타입은 여전히 'unknown'이거나
// `{ id: unknown; name: unknown; ... }` 일 뿐, 'Product'라고 확신하지 못한다.
// 그래서 또다시 타입 단언을 사용해야만 한다.
console.log((response as Product).name);
}
}
이런 지저분하고 재사용하기 어려운 코드를 어떻게 개선할 수 있을까?
이번에는 is 키워드를 사용하는 커스텀 타입 가드(Custom Type Guard)를 직접 만들어, 복잡한 객체의 타입을 명확하고 안전하게 검증하는 방법을 알아보려고 한다.
1. is 키워드: 단순한 boolean을 넘어서
is 키워드는 함수의 반환 값에 대한 타입 명제를 선언하는 특별한 문법이다. 일반적인 boolean 반환 함수와 is를 사용한 타입 가드를 비교하면 그 차이를 명확히 알 수 있다.
1. 일반 boolean 반환 함수
이 함수는 value가 문자열인지 아닌지 true/false로 알려주지만, 그 정보를 타입스크립트의 타입 추론 시스템과 공유하지는 않는다.
function isString_Normal(value: unknown): boolean {
return typeof value === 'string';
}
function check(value: unknown) {
if (isString_Normal(value)) {
// isString_Normal이 true여도, 타입스크립트는 이 사실을 타입 추론에 활용하지 못한다.
// 따라서 'value'는 여전히 unknown 타입이다.
// 에러: 'value' is of type 'unknown'.
// value.toUpperCase();
}
}
1. is를 사용한 타입 가드
value is string이라는 반환 타입은 타입스크립트에게 "이 함수가 true를 반환하면, 너는 value의 타입을 string으로 간주해도 좋다" 라고 말해주는 계약과 같다.
function isString_TypeGuard(value: unknown): value is string {
return typeof value === 'string';
}
function check(value: unknown) {
if (isString_TypeGuard(value)) {
// isString_TypeGuard가 true를 반환했으므로,
// 타입스크립트는 '계약'에 따라 value의 타입을 string으로 좁힌다.
// 이제 string의 모든 메서드를 안전하게 사용할 수 있다.
console.log(value.toUpperCase());
}
}
타입 서술(Type Predicate)이란, 단순히 true/false만 반환하는 함수가 아니라 "이 함수가 true를 반환하면, 내가 검사한 저 값은 앞으로 이 타입이라고 간주해도 좋아" 라고 타입스크립트 컴파일러에게 확신을 주는 특별한 형태의 함수이다.
value is string 부분이 바로 타입 서술이다. isString 함수가 true를 반환하면, 타입스크립트는 if 블록 안에서 value의 타입을 string으로 똑똑하게 추론한다. 더 이상 as string 같은 강제 타입 단언이 필요 없어지는 것이다.
is 키워드의 역할을 여권 심사관에 비유할 수 있을 것이다.
-
일반 함수 (: boolean): 공항 게이트의 통과 여부(true/false)만 알려주는 직원과 같다. 이 직원은 당신이 통과할 수 있는지 없는지만 판단할 뿐, 당신이 '어느 나라 사람인지'에 대한 공식적인 정보를 다른 시스템에 알려주지 않는다.
-
타입 가드 (is Type): 여권(값)을 확인하고, 통과 여부를 결정함과 동시에 "이 사람은 대한민국 국민입니다" 라고 시스템 전체에 공표하는 심사관과 같다. 이 공표 덕분에, 게이트를 통과한 당신은 '대한민국 국민'으로서의 권리(타입에 맞는 메서드와 속성 접근)를 누릴 수 있게 된다.
이처럼 is 키워드는 런타임의 값 검사(예: typeof) 결과를 컴파일 타임의 타입 시스템에 연결해주는 매우 중요한 다리 역할을 한다.
2. 실전 예제: API 응답 객체 검증하기
먼저 검증하고 싶은 목표 타입인 Product 인터페이스를 다시 정의해 보려고 한다.
interface Product {
id: number;
name: string;
inStock: boolean;
}
이제 이 Product 타입을 위한 전용 타입 가드 함수, isProduct를 만들어 보자. 이 함수는 unknown 타입의 값을 받아, 그 값이 Product의 구조와 일치하는지 검사하고 boolean을 반환한다. 그리고 가장 중요한 is 키워드를 사용해 반환 타입을 value is Product로 지정한다.
// Product 타입을 위한 커스텀 타입 가드
function isProduct(value: unknown): value is Product {
// 1. value가 null이 아니고, 타입이 'object'인지 확인한다.
if (value === null || typeof value !== 'object') {
return false;
}
// 2. 필요한 모든 속성(id, name, inStock)이 value 객체 안에 있는지 확인한다.
return 'id' in value && 'name' in value && 'inStock' in value;
// (더 엄격하게 하려면 각 속성의 타입까지 검사할 수도 있다.)
// e.g., return typeof (value as any).id === 'number' && ...
}
// --- 타입 가드 사용하기 ---
function processApiResponse(response: unknown) {
// Before: 복잡하고 지저분했던 조건문
// if (response && typeof response === 'object' && 'id' in response && ...)
// After: 깔끔하고 의미가 명확한 함수 호출
if (isProduct(response)) {
// 이 블록 안에서 'response'는 완벽하게 'Product' 타입으로 추론된다.
// 더 이상 'as Product' 같은 타입 단언이 필요 없다.
console.log(`상품명: ${response.name}, 재고: ${response.inStock ? '있음' : '없음'}`);
} else {
console.error("오류: 유효하지 않은 상품 데이터입니다.");
}
}
// 테스트
processApiResponse({ id: 1, name: "노트북", inStock: true }); // "상품명: 노트북, 재고: 있음"
processApiResponse({ id: 2, name: "키보드" }); // "오류: 유효하지 않은 상품 데이터입니다." (inStock 속성 없음)
processApiResponse("This is a string"); // "오류: 유효하지 않은 상품 데이터입니다."
무엇이 더 나아진걸까?
- 가독성: if (isProduct(response)) 라는 코드는 if (value && typeof ...) 보다 훨씬 읽기 쉽고, "이 값이 상품(Product)인가?" 라는 개발자의 의도를 명확하게 드러낸다.
- 재사용성: isProduct 함수는 이제 프로젝트의 어느 곳에서든 Product 타입을 검증해야 할 때 재사용할 수 있다.
- 신뢰성: Product 인터페이스에 새로운 속성이 추가되거나 변경될 경우, isProduct 함수 한 곳만 수정하면 되므로 실수를 줄이고 유지보수하기 쉬워진다.
3. 커스텀 타입 가드의 장점과 추가 활용법
앞선 예제를 통해 우리는 이미 커스텀 타입 가드의 강력함을 확인했다. 그 장점을 다시 한번 정리하면 다음과 같다.
- 가독성 (Readability): 복잡한 타입 검증 로직을 isProduct(value)처럼 의미가 명확한 함수 하나로 캡슐화하여 코드의 의도를 쉽게 파악할 수 있다.
- 재사용성 (Reusability): 한번 잘 만들어둔 타입 가드 함수는 프로젝트 내에서 동일한 타입 검증이 필요한 모든 곳에서 재사용할 수 있다.
- 중앙 관리 (Centralization): 타입의 정의(Product 인터페이스)가 변경될 경우, 여러 곳에 흩어진 검증 로직을 찾아다닐 필요 없이 중앙에 있는 타입 가드 함수 하나만 수정하면 되므로 유지보수가 매우 쉬워진다.
추가 활용법: 배열 filter 메서드와 함께 사용하기
커스텀 타입 가드의 진가는 if문을 넘어 배열과 함께 사용될 때 더욱 빛을 발한다.
예를 들어, API에서 성공한 데이터(Product)와 실패한 데이터(ApiError)가 섞인 배열을 받았다고 가정해보자. 여기서 Product 객체만 안전하게 필터링하고 싶다면 어떻게 해야 할까?
// isProduct 함수는 이전에 정의했다고 가정하자.
interface Product { id: number; name: string; inStock: boolean; }
interface ApiError { code: number; message: string; }
const mixedResponses: (Product | ApiError)[] = [
{ id: 1, name: "노트북", inStock: true },
{ code: 404, message: "Not Found" },
{ id: 2, name: "키보드", inStock: false },
{ code: 500, message: "Server Error" },
];
// 일반적인 필터링
// TypeScript는 filter 내부 로직을 통해 타입을 좁혀주지 못한다.
// 따라서 filtered1의 타입은 여전히 (Product | ApiError)[] 인 것이다.
const filtered1 = mixedResponses.filter(item => 'id' in item);
// 커스텀 타입 가드를 사용한 필터링
// isProduct 타입 가드를 filter의 인자로 전달하면,
// TypeScript는 필터링된 결과가 Product[] 타입임을 '확신'할 수 있다.
const productList: Product[] = mixedResponses.filter(isProduct);
console.log(productList);
// 결과: [{ id: 1, ... }, { id: 2, ... }]
// 이제 productList 배열의 모든 요소에 대해 .name, .inStock 속성을
// 에러 없이 안전하게 접근할 수 있다.
productList.forEach(product => console.log(product.name));
Array.prototype.filter는 타입 서술을 인자로 받을 수 있도록 특별히 설계 되었다. 이 덕분에 우리는 필터링과 동시에 타입까지 완벽하게 좁혀진 새로운 배열을 얻을 수 있다.
4. 결론: 스스로 타입을 증명하는 코드
typeof만으로는 객체의 구체적인 형태를 증명할 수 없다는 문제에서 출발하여, is 키워드를 사용한 커스텀 타입 가드를 통해 우리만의 타입 검증 규칙을 만드는 방법을 알아봤다.
커스텀 타입 가드의 진정한 힘은 단순히 코드를 간결하게 만드는 것을 넘어, '타입을 추측하는 코드'에서 '타입을 증명하는 코드'로 패러다임을 전환하는 데에 있다.
as를 사용한 타입 단언이 "이건 이 타입일 거야"라고 가정하는 위험한 추측이라면, 타입 가드는 "이것은 이런이런 근거로 이 타입이 확실해"라고 논리적으로 증명하는 과정인 것이다.
첫 번째 글에서 any를 쓰지 않기로 다짐한 것이 '타입 시스템의 보호를 받겠다'는 것에서 그쳤다면, 커스텀 타입 가드를 작성하는 것은 타입 시스템을 내 의도대로 똑똑하게 만들겠다는 생각까지 확장 되었다.
좋은 타입스크립트 코드는 단지 에러가 없는 코드가 아니라, 타입 그 자체가 문서가 되고, 테스트가 되며, 동료 개발자를 위한 최고의 가이드가 되는 코드라고 생각한다.