타입 가드(Type Guard) - 런타임에서 타입을 안전하게 좁히기
컴파일 타임 안전함과 런타임 현실 사이
처음 TypeScript를 도입했을 때 가장 당황스러웠던 순간은 API 응답을 처리할 때였다. 인터페이스로 타입을 정의해두면 안전할 거라 믿었는데, 실제로는 런타임에 뭐가 날아올지 아무도 몰랐다. 성공 응답일 수도 있고, 에러일 수도 있고, 심지어 예상치 못한 형태일 수도 있었다.
마치 택배를 받는 것 같았다. 송장에는 '노트북'이라고 적혀있지만 박스를 열어보기 전까진 정말 노트북인지, 벽돌인지, 아니면 빈 박스인지 알 수 없는 상황. TypeScript의 타입 시스템은 송장을 믿으라고 했지만, 현실의 런타임은 그렇지 않았다.
타입 가드를 이해한 후, 비로소 이 간극을 메울 수 있었다. 단순히 타입을 주장(assertion)하는 게 아니라, 실제로 검사하고 좁혀나가는 방법을 알게 된 것이다.
Aha Moment: as는 거짓말, is는 증명
가장 큰 깨달음은 as와 타입 가드의 차이를 이해했을 때였다.
// as: 컴파일러에게 거짓말하기
const response = await fetch('/api/user');
const data = await response.json() as User; // 믿거나 말거나
data.name.toUpperCase(); // 런타임 에러 가능성 100%
// Type Guard: 실제로 확인하기
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
typeof obj.name === 'string'
);
}
const response = await fetch('/api/user');
const data = await response.json();
if (isUser(data)) {
data.name.toUpperCase(); // 안전함
} else {
console.error('Invalid user data');
}
as는 마치 "이건 노트북이야"라고 박스에 스티커를 붙이는 것이었다. 내용물과 상관없이. 반면 타입 가드는 실제로 박스를 열어서 "화면이 있나? 키보드가 있나? 그럼 노트북이 맞네"라고 확인하는 과정이었다.
타입 가드의 다양한 무기들
1. typeof: 원시 타입의 문지기
가장 기본적인 타입 가드. JavaScript에 내장된 typeof 연산자를 활용한다.
function processValue(value: string | number) {
if (typeof value === 'string') {
// 여기서 value는 string
return value.toUpperCase();
}
// 여기서 value는 number
return value.toFixed(2);
}
// 구체적인 예제: 환경변수 처리
function getPort(port: string | number | undefined): number {
if (typeof port === 'number') {
return port;
}
if (typeof port === 'string') {
const parsed = parseInt(port, 10);
return isNaN(parsed) ? 3000 : parsed;
}
return 3000; // undefined일 때 기본값
}
원시 타입을 다룰 때는 typeof가 가장 직관적이다. TypeScript 컴파일러도 이 패턴을 완벽하게 이해하고 타입을 좁혀준다.
2. instanceof: 클래스 인스턴스 확인
객체가 특정 클래스의 인스턴스인지 확인할 때 사용한다.
class NetworkError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}
class ValidationError extends Error {
constructor(public field: string, message: string) {
super(message);
}
}
function handleError(error: Error) {
if (error instanceof NetworkError) {
console.error(`Network error ${error.statusCode}: ${error.message}`);
// 재시도 로직
} else if (error instanceof ValidationError) {
console.error(`Invalid ${error.field}: ${error.message}`);
// 사용자에게 피드백
} else {
console.error('Unknown error:', error.message);
}
}
에러 처리에서 특히 유용했다. 각 에러 타입마다 다른 처리 로직을 적용할 수 있게 되었다.
3. in 연산자: 속성의 존재로 타입 판별
객체에 특정 속성이 있는지로 타입을 구분한다.
interface SuccessResponse {
success: true;
data: User[];
}
interface ErrorResponse {
success: false;
error: string;
code: number;
}
type ApiResponse = SuccessResponse | ErrorResponse;
function handleResponse(response: ApiResponse) {
if ('data' in response) {
// response는 SuccessResponse
console.log(`Loaded ${response.data.length} users`);
} else {
// response는 ErrorResponse
console.error(`Error ${response.code}: ${response.error}`);
}
}
이 패턴은 API 응답 처리에서 정말 자주 쓰인다. 성공/실패를 명확하게 구분할 수 있다.
4. Custom Type Guard: is 키워드로 직접 만들기
가장 강력한 도구. 복잡한 조건도 타입 가드로 만들 수 있다.
interface User {
id: string;
email: string;
role: 'admin' | 'user';
}
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
typeof (obj as any).id === 'string' &&
'email' in obj &&
typeof (obj as any).email === 'string' &&
'role' in obj &&
((obj as any).role === 'admin' || (obj as any).role === 'user')
);
}
// 실제 사용
async function loadUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user data from API');
}
return data; // 여기서 data는 User 타입
}
처음엔 번거로워 보였지만, 한 번 작성해두면 코드 전체에서 재사용할 수 있다. 특히 외부 데이터를 다룰 때 필수적이었다.
5. Discriminated Unions: 공통 속성으로 구분하기
유니언 타입에 공통 속성(discriminant)을 두면, 그 값으로 타입을 자동으로 좁힐 수 있다.
interface LoadingState {
status: 'loading';
}
interface SuccessState {
status: 'success';
data: User[];
}
interface ErrorState {
status: 'error';
error: string;
}
type State = LoadingState | SuccessState | ErrorState;
function renderUI(state: State) {
switch (state.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={state.data} />; // data 자동 인식
case 'error':
return <ErrorMessage message={state.error} />; // error 자동 인식
}
}
React 상태 관리에서 이 패턴을 쓰기 시작하면서 코드가 놀라울 정도로 깔끔해졌다. status 하나로 모든 상태를 명확하게 구분할 수 있었다.
6. Assertion Functions: asserts 키워드
타입 가드의 변형. 조건을 만족하지 않으면 에러를 던지고, 만족하면 이후 코드에서 타입이 좁혀진다.
function assertIsUser(obj: unknown): asserts obj is User {
if (!isUser(obj)) {
throw new Error('Not a valid user object');
}
}
// 사용
async function updateUser(id: string, updates: unknown) {
assertIsUser(updates); // 여기서 검증
// 이후 updates는 User 타입
await db.users.update(id, updates);
}
// 또 다른 예제: null 체크
function assertIsDefined<T>(value: T): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error('Value must be defined');
}
}
function processUser(user: User | null) {
assertIsDefined(user);
// 이후 user는 User 타입 (null 제외됨)
console.log(user.email);
}
선언적으로 검증 로직을 작성할 수 있어서 좋았다. 에러를 일찍 던져서 디버깅도 쉬워졌다.
7. Exhaustive Checking: never로 빠진 케이스 잡기
모든 경우를 처리했는지 컴파일 타임에 확인하는 패턴.
type PaymentMethod = 'credit_card' | 'paypal' | 'crypto';
function processPayment(method: PaymentMethod) {
switch (method) {
case 'credit_card':
return chargeCreditCard();
case 'paypal':
return chargePaypal();
case 'crypto':
return chargeCrypto();
default:
// 모든 케이스를 처리했다면 여기는 never 타입
const _exhaustive: never = method;
throw new Error(`Unhandled payment method: ${_exhaustive}`);
}
}
// PaymentMethod에 'bank_transfer' 추가하면?
// 컴파일 에러 발생! default 케이스에서 never에 할당 불가
새로운 타입이 추가되면 컴파일러가 알려준다. 런타임 에러를 사전에 방지하는 강력한 도구다.
실제 패턴: API 응답 처리의 진화
초기 코드:
// 위험천만
async function fetchUsers() {
const response = await fetch('/api/users');
const data = await response.json() as User[];
return data; // 뭐가 와도 User[]로 믿음
}
타입 가드 적용 후:
// 안전하고 명확함
interface ApiSuccess<T> {
success: true;
data: T;
}
interface ApiError {
success: false;
error: string;
code: number;
}
type ApiResponse<T> = ApiSuccess<T> | ApiError;
function isApiSuccess<T>(
response: ApiResponse<T>
): response is ApiSuccess<T> {
return response.success === true;
}
async function fetchUsers(): Promise<User[]> {
const response = await fetch('/api/users');
const json = await response.json() as ApiResponse<User[]>;
if (!isApiSuccess(json)) {
throw new Error(`API Error ${json.code}: ${json.error}`);
}
return json.data;
}
Zod와 Valibot: 런타임 검증의 새 지평
수동으로 타입 가드를 작성하는 건 번거롭다. 특히 중첩된 객체나 배열이 있으면 지옥이다. 이때 Zod나 Valibot 같은 스키마 검증 라이브러리가 게임 체인저였다.
import { z } from 'zod';
// 스키마 정의 = 타입 + 런타임 검증
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(['admin', 'user']),
createdAt: z.string().datetime(),
});
type User = z.infer<typeof UserSchema>;
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
// 한 줄로 파싱 + 검증
return UserSchema.parse(data);
}
// 에러를 던지지 않고 Result 타입으로 받기
async function fetchUserSafe(id: string) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
const result = UserSchema.safeParse(data);
if (!result.success) {
console.error('Validation errors:', result.error.issues);
return null;
}
return result.data; // User 타입
}
Valibot은 Zod보다 가볍고 트리 쉐이킹이 잘 된다:
import * as v from 'valibot';
const UserSchema = v.object({
id: v.string([v.uuid()]),
email: v.string([v.email()]),
role: v.picklist(['admin', 'user']),
createdAt: v.string([v.isoDateTime()]),
});
type User = v.Output<typeof UserSchema>;
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return v.parse(UserSchema, data);
}
스키마 하나로 타입 정의와 런타임 검증을 동시에 해결한다. API 경계에서 이걸 쓰면 타입 안전성이 실시간으로 보장된다.
흔한 실수: as 남용의 유혹
타입 가드를 알기 전엔 막힐 때마다 as를 남발했다.
// 나쁜 예
const user = response.data as User;
const element = document.querySelector('.button') as HTMLButtonElement;
const config = JSON.parse(text) as Config;
이건 컴파일러에게 "닥치고 믿어"라고 하는 것과 같다. 런타임 폭탄을 심는 행위다.
타입 가드를 쓰면:
// 좋은 예
if (isUser(response.data)) {
const user = response.data;
}
const element = document.querySelector('.button');
if (element instanceof HTMLButtonElement) {
element.click();
}
const config = JSON.parse(text);
if (isConfig(config)) {
useConfig(config);
}
귀찮아 보여도, 런타임 에러 한 번만 겪으면 생각이 바뀐다. 타입 가드는 보험이다.
정리: 타입 시스템과 런타임의 다리
타입 가드는 TypeScript의 정적 타입 시스템과 JavaScript의 동적 런타임 사이를 연결하는 다리다.
핵심을 정리하면:
- typeof: 원시 타입(
string,number,boolean등) 구분 - instanceof: 클래스 인스턴스 확인
- in: 속성 존재 여부로 타입 판별
- Custom Type Guard:
is키워드로 복잡한 검증 로직 캡슐화 - Discriminated Unions: 공통 속성(
status,type등)으로 자동 타입 좁히기 - Assertion Functions:
asserts키워드로 조건 검증 + 타입 좁히기 - Exhaustive Checking:
never타입으로 빠진 케이스 컴파일 타임 체크 - Zod/Valibot: 스키마 기반 런타임 검증과 타입 추론 자동화
실제로 배운 교훈:
- API 경계에서는 항상 타입 가드나 스키마 검증을 써라
as는 정말 확신할 때만 (DOM API 등)- Discriminated unions는 상태 관리의 베스트 프랙티스
- Zod/Valibot은 복잡한 타입에 필수
타입 가드를 이해하고 나서, TypeScript가 단순히 주석을 다는 도구가 아니라 진짜 런타임 안전성을 높이는 도구라는 걸 실감했다. 컴파일 타임과 런타임, 두 세계를 하나로 묶는 열쇠였다.