
타입 가드(Type Guard): 런타임에서 타입을 안전하게 좁히기
API 응답이 성공일 수도, 에러일 수도 있는 유니언 타입을 다룰 때, 타입 가드를 알기 전과 후가 완전히 달랐다.

API 응답이 성공일 수도, 에러일 수도 있는 유니언 타입을 다룰 때, 타입 가드를 알기 전과 후가 완전히 달랐다.
any를 쓰면 타입스크립트를 쓰는 의미가 없습니다. 제네릭(Generics)을 통해 유연하면서도 타입 안전성(Type Safety)을 모두 챙기는 방법을 정리합니다. infer, keyof 등 고급 기법 포함.

서비스를 MSA로 쪼갰더니 트랜잭션 관리가 지옥이 되었습니다. 주문은 성공했는데 결제는 실패하고, 재고는 이미 차감되었다면? 모놀리식의 ACID가 그리워지는 순간, 분산 환경에서 데이터 일관성을 지키는 Two-Phase Commit(2PC), Saga 패턴(Choreography, Orchestration)을 구체적인 예제와 함께 다뤄봤습니다.

부모에서 전달한 props가 undefined로 나와서 앱이 크래시되는 문제 해결

npm install이 3분 걸리던 프로젝트가 bun install로 10초. 빠르다는 건 알겠는데, 실제로 프로덕션에 쓸 수 있을까?

처음 TypeScript를 도입했을 때 가장 당황스러웠던 순간은 API 응답을 처리할 때였다. 인터페이스로 타입을 정의해두면 안전할 거라 믿었는데, 실제로는 런타임에 뭐가 날아올지 아무도 몰랐다. 성공 응답일 수도 있고, 에러일 수도 있고, 심지어 예상치 못한 형태일 수도 있었다.
마치 택배를 받는 것 같았다. 송장에는 '노트북'이라고 적혀있지만 박스를 열어보기 전까진 정말 노트북인지, 벽돌인지, 아니면 빈 박스인지 알 수 없는 상황. TypeScript의 타입 시스템은 송장을 믿으라고 했지만, 현실의 런타임은 그렇지 않았다.
타입 가드를 이해한 후, 비로소 이 간극을 메울 수 있었다. 단순히 타입을 주장(assertion)하는 게 아니라, 실제로 검사하고 좁혀나가는 방법을 알게 된 것이다.
가장 큰 깨달음은 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는 마치 "이건 노트북이야"라고 박스에 스티커를 붙이는 것이었다. 내용물과 상관없이. 반면 타입 가드는 실제로 박스를 열어서 "화면이 있나? 키보드가 있나? 그럼 노트북이 맞네"라고 확인하는 과정이었다.
가장 기본적인 타입 가드. 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 컴파일러도 이 패턴을 완벽하게 이해하고 타입을 좁혀준다.
객체가 특정 클래스의 인스턴스인지 확인할 때 사용한다.
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);
}
}
에러 처리에서 특히 유용했다. 각 에러 타입마다 다른 처리 로직을 적용할 수 있게 되었다.
객체에 특정 속성이 있는지로 타입을 구분한다.
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 응답 처리에서 정말 자주 쓰인다. 성공/실패를 명확하게 구분할 수 있다.
가장 강력한 도구. 복잡한 조건도 타입 가드로 만들 수 있다.
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 타입
}
처음엔 번거로워 보였지만, 한 번 작성해두면 코드 전체에서 재사용할 수 있다. 특히 외부 데이터를 다룰 때 필수적이었다.
유니언 타입에 공통 속성(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 하나로 모든 상태를 명확하게 구분할 수 있었다.
타입 가드의 변형. 조건을 만족하지 않으면 에러를 던지고, 만족하면 이후 코드에서 타입이 좁혀진다.
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);
}
선언적으로 검증 로직을 작성할 수 있어서 좋았다. 에러를 일찍 던져서 디버깅도 쉬워졌다.
모든 경우를 처리했는지 컴파일 타임에 확인하는 패턴.
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에 할당 불가
새로운 타입이 추가되면 컴파일러가 알려준다. 런타임 에러를 사전에 방지하는 강력한 도구다.
초기 코드:
// 위험천만
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 같은 스키마 검증 라이브러리가 게임 체인저였다.
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를 남발했다.
// 나쁜 예
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의 동적 런타임 사이를 연결하는 다리다.
핵심을 정리하면:string, number, boolean 등) 구분is 키워드로 복잡한 검증 로직 캡슐화status, type 등)으로 자동 타입 좁히기asserts 키워드로 조건 검증 + 타입 좁히기never 타입으로 빠진 케이스 컴파일 타임 체크as는 정말 확신할 때만 (DOM API 등)타입 가드를 이해하고 나서, TypeScript가 단순히 주석을 다는 도구가 아니라 진짜 런타임 안전성을 높이는 도구라는 걸 실감했다. 컴파일 타임과 런타임, 두 세계를 하나로 묶는 열쇠였다.