TypeScript 타입 가드
API 응답을 받았다. 타입은 unknown이다. 이 데이터가 우리가 기대하는 User 객체인지 확인하고 싶다. as User로 단언하면 컴파일러는 만족하지만, 런타임에 실제로 그 타입인지는 전혀 검증되지 않는다. 잘못된 데이터가 들어오면 어딘가에서 Cannot read property of undefined가 터진다.
타입 가드는 이 문제를 해결한다. 런타임에 실제로 값을 검사하면서, 그 결과를 TypeScript 컴파일러에게 알려주는 메커니즘이다. 검사를 통과한 코드 블록 안에서는 타입이 자동으로 좁혀지기 때문에 별도의 단언 없이도 안전하게 속성에 접근할 수 있다.
내장 타입 가드
TypeScript는 JavaScript의 기본 연산자를 타입 가드로 인식한다. 이미 무의식적으로 쓰고 있을 가능성이 높다.
typeof
원시 타입을 구분할 때 사용한다. typeof의 결과로 "string", "number", "boolean", "bigint", "symbol", "undefined", "object", "function" 중 하나가 반환된다.
function formatValue(value: string | number) {
if (typeof value === "string") {
// 이 블록에서 value는 string
return value.toUpperCase();
}
// 여기서 value는 number
return value.toFixed(2);
}
TypeScript는 typeof 검사 후 해당 분기에서 타입을 자동으로 좁힌다. 이것이 타입 내로잉(narrowing)이다. else 블록에서는 검사된 타입이 제거된 나머지 타입만 남는다.
typeof의 한계는 명확하다. typeof null === "object"이고, 배열도 "object"다. 객체의 구체적인 형태를 구분하는 데는 쓸 수 없다.
typeof null; // "object" — JavaScript의 유명한 버그
typeof []; // "object"
typeof {}; // "object"
typeof new Date(); // "object"
instanceof
클래스 인스턴스를 구분할 때 사용한다. 프로토타입 체인을 따라 올라가면서 해당 생성자의 인스턴스인지 확인한다.
class ApiError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}
class ValidationError extends Error {
constructor(public fields: string[]) {
super("Validation failed");
}
}
function handleError(error: Error) {
if (error instanceof ApiError) {
// error는 ApiError — statusCode 접근 가능
console.log(`API 오류 ${error.statusCode}: ${error.message}`);
} else if (error instanceof ValidationError) {
// error는 ValidationError — fields 접근 가능
console.log(`검증 실패: ${error.fields.join(", ")}`);
} else {
console.log(`알 수 없는 오류: ${error.message}`);
}
}
instanceof는 클래스(생성자 함수)가 있어야 동작한다. 일반 인터페이스나 타입 별칭은 런타임에 존재하지 않으므로 instanceof로 검사할 수 없다. 이것이 사용자 정의 타입 가드가 필요한 핵심 이유다.
in 연산자
객체에 특정 속성이 존재하는지 확인한다. 인터페이스를 구분하는 간편한 방법이다.
interface Bird {
fly(): void;
layEggs(): void;
}
interface Fish {
swim(): void;
layEggs(): void;
}
function move(animal: Bird | Fish) {
if ("fly" in animal) {
// animal은 Bird
animal.fly();
} else {
// animal은 Fish
animal.swim();
}
}
in 연산자는 프로토타입 체인까지 포함해서 속성 존재 여부를 확인한다. 속성 이름만으로 판단하므로 두 타입이 같은 이름의 속성을 가지면 구분이 불가능하다.
동등성 좁히기
===, !==, ==, != 비교도 타입을 좁힌다. 특히 리터럴 타입이나 null 체크에서 유용하다.
function example(x: string | number, y: string | boolean) {
if (x === y) {
// x와 y가 같으려면 둘 다 string이어야 함
// x: string, y: string
x.toUpperCase();
y.toUpperCase();
}
}
function processValue(value: string | null | undefined) {
if (value != null) {
// null과 undefined 둘 다 제거됨
// value: string
console.log(value.length);
}
}
== null은 null과 undefined 둘 다 걸러낸다. === null은 null만 걸러낸다. 의도에 따라 적절히 선택하면 된다.
사용자 정의 타입 가드
내장 타입 가드로 충분하지 않은 경우가 많다. API 응답 검증, 유니온 타입 중 특정 타입 식별, unknown 타입 좁히기 등에서는 직접 타입 가드를 정의해야 한다.
타입 술어 (Type Predicate)
사용자 정의 타입 가드의 핵심은 반환 타입에 parameterName is Type 형태의 타입 술어를 쓰는 것이다.
interface User {
id: number;
name: string;
email: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
"email" in value &&
typeof (value as User).id === "number" &&
typeof (value as User).name === "string" &&
typeof (value as User).email === "string"
);
}
이 함수가 true를 반환하면 TypeScript는 해당 값이 User 타입이라고 판단한다. 호출 측에서는 이렇게 쓴다:
const data: unknown = await fetchUser();
if (isUser(data)) {
// data는 User — 안전하게 접근 가능
console.log(data.name, data.email);
} else {
throw new Error("Invalid user data");
}
타입 술어가 없으면 어떻게 될까? 반환 타입을 그냥 boolean으로 쓰면:
function isUser(value: unknown): boolean {
// ... 동일한 검사 로직
}
if (isUser(data)) {
// data는 여전히 unknown — 타입이 좁혀지지 않음!
data.name; // Error: 'unknown' 형식에 'name' 속성이 없습니다
}
boolean만 반환하면 TypeScript는 그 함수가 타입을 좁히는 역할을 한다는 것을 알 수 없다. 타입 술어가 컴파일러와의 계약인 셈이다.
타입 가드의 책임
중요한 점이 있다. TypeScript는 타입 가드 함수의 내부 로직이 정확한지 검증하지 않는다. 타입 술어는 개발자가 컴파일러에게 "내가 보장한다"고 말하는 것이다.
// 위험한 타입 가드 — 항상 true를 반환
function isDangerous(value: unknown): value is User {
return true; // 아무것도 검사하지 않음!
}
const garbage = "hello";
if (isDangerous(garbage)) {
// TypeScript는 garbage가 User라고 믿지만 실제론 string
console.log(garbage.id); // 런타임 에러!
}
타입 가드 안에서 충분한 검증을 하지 않으면 as 단언과 다를 바가 없다. 타입 가드의 존재 이유는 런타임 검사와 컴파일 타임 타입 정보를 연결하는 것이므로, 런타임 검사를 빼먹으면 의미가 없다.
unknown에서 구체 타입으로
외부에서 들어오는 데이터를 안전하게 파싱하는 가장 실용적인 패턴이다.
interface ApiResponse<T> {
success: boolean;
data: T;
error?: string;
}
interface Product {
id: string;
name: string;
price: number;
tags: string[];
}
function isProduct(value: unknown): value is Product {
if (typeof value !== "object" || value === null) return false;
const obj = value as Record<string, unknown>;
return (
typeof obj.id === "string" &&
typeof obj.name === "string" &&
typeof obj.price === "number" &&
Array.isArray(obj.tags) &&
obj.tags.every((tag) => typeof tag === "string")
);
}
function isApiResponse<T>(
value: unknown,
isData: (v: unknown) => v is T
): value is ApiResponse<T> {
if (typeof value !== "object" || value === null) return false;
const obj = value as Record<string, unknown>;
return (
typeof obj.success === "boolean" &&
(obj.error === undefined || typeof obj.error === "string") &&
isData(obj.data)
);
}
제네릭 타입 가드 isApiResponse는 데이터 검증 함수를 인자로 받는다. 이렇게 하면 응답 구조 검증과 데이터 검증을 분리할 수 있어서 재사용성이 높아진다.
const raw: unknown = JSON.parse(responseBody);
if (isApiResponse(raw, isProduct)) {
// raw는 ApiResponse<Product>
console.log(raw.data.name, raw.data.price);
raw.data.tags.forEach((tag) => console.log(tag));
}
배열 타입 가드
배열의 각 요소가 특정 타입인지 확인하는 패턴도 자주 쓰인다.
function isArrayOf<T>(
value: unknown,
guard: (item: unknown) => item is T
): value is T[] {
return Array.isArray(value) && value.every(guard);
}
function isString(value: unknown): value is string {
return typeof value === "string";
}
function isNumber(value: unknown): value is number {
return typeof value === "number" && !Number.isNaN(value);
}
// 사용
const input: unknown = [1, 2, 3];
if (isArrayOf(input, isNumber)) {
// input은 number[]
const sum = input.reduce((a, b) => a + b, 0);
}
isArrayOf는 범용 배열 타입 가드다. 원시 타입 가드를 조합해서 isArrayOf(data, isString), isArrayOf(data, isProduct) 등으로 활용할 수 있다.
필터에서의 타입 가드
Array.prototype.filter()와 타입 가드를 조합하면 배열에서 특정 타입만 추출하면서 타입도 자동으로 좁힐 수 있다.
const items: (string | null | undefined)[] = [
"hello",
null,
"world",
undefined,
"foo",
];
// 타입 가드 없이 — 결과 타입이 (string | null | undefined)[]
const filtered1 = items.filter((item) => item != null);
// 타입 가드 사용 — 결과 타입이 string[]
const filtered2 = items.filter(
(item): item is string => item != null
);
이 패턴은 실무에서 매우 자주 쓰인다. API에서 받은 배열에 null이 섞여 있을 때, 혹은 유니온 타입 배열에서 특정 타입만 골라낼 때 유용하다.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number };
const shapes: Shape[] = [
{ kind: "circle", radius: 5 },
{ kind: "square", side: 10 },
{ kind: "circle", radius: 3 },
{ kind: "triangle", base: 4, height: 6 },
];
// circle만 추출 — 타입이 자동으로 { kind: "circle"; radius: number }[]
const circles = shapes.filter(
(s): s is Extract<Shape, { kind: "circle" }> => s.kind === "circle"
);
circles.forEach((c) => {
console.log(c.radius); // 안전하게 접근
});
Discriminated Union과 타입 가드
TypeScript에서 가장 강력한 타입 좁히기 패턴은 판별 유니온(Discriminated Union)이다. 공통 리터럴 필드(kind, type, status 등)를 두고 switch나 if로 분기하면 각 분기에서 타입이 자동으로 좁혀진다.
interface LoadingState {
status: "loading";
}
interface SuccessState {
status: "success";
data: string[];
}
interface ErrorState {
status: "error";
error: Error;
}
type State = LoadingState | SuccessState | ErrorState;
function render(state: State) {
switch (state.status) {
case "loading":
// state: LoadingState
return "로딩 중...";
case "success":
// state: SuccessState
return state.data.join(", ");
case "error":
// state: ErrorState
return state.error.message;
}
}
판별 유니온은 사용자 정의 타입 가드가 필요 없다. 리터럴 타입 필드를 비교하는 것만으로 TypeScript가 알아서 좁혀준다. 가능하면 판별 유니온을 먼저 고려하고, 그것으로 불가능할 때 사용자 정의 타입 가드를 쓰는 것이 좋다.
판별 유니온이 불가능한 경우
판별 유니온은 내가 타입을 설계할 수 있을 때 사용할 수 있다. 외부 라이브러리 타입, API 응답, unknown 데이터처럼 판별 필드가 없는 경우에는 사용자 정의 타입 가드가 필요하다.
// 외부 API 응답 — 판별 필드가 없음
interface UserProfile {
name: string;
bio: string;
followers: number;
}
interface CompanyProfile {
name: string;
industry: string;
employees: number;
}
// name이 겹치고, 판별 필드가 없다
// → 사용자 정의 타입 가드가 필요
function isCompanyProfile(
profile: UserProfile | CompanyProfile
): profile is CompanyProfile {
return "industry" in profile;
}
assertion 함수 (asserts 키워드)
TypeScript 3.7에서 도입된 assertion 함수는 타입 가드의 변형이다. 값이 조건을 만족하지 않으면 예외를 던지고, 만족하면 이후 코드에서 타입이 좁혀진다.
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new TypeError(`Expected string, got ${typeof value}`);
}
}
function assertIsDefined<T>(
value: T
): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error("Expected value to be defined");
}
}
is 타입 가드와의 차이는 제어 흐름이다:
// is 타입 가드 — if 블록 안에서만 좁혀짐
function isString(value: unknown): value is string {
return typeof value === "string";
}
const val: unknown = getData();
if (isString(val)) {
val.toUpperCase(); // ✅ 여기서만 string
}
val.toUpperCase(); // ❌ 여전히 unknown
// asserts 타입 가드 — 이후 모든 코드에서 좁혀짐
assertIsString(val);
val.toUpperCase(); // ✅ 여기서부터 쭉 string
assertion 함수는 함수 초반에 전제 조건을 검증하는 패턴에 적합하다. 조건을 만족하지 않으면 함수가 즉시 종료되므로, 이후 코드에서는 타입이 보장된다.
interface Config {
apiUrl: string;
apiKey: string;
timeout: number;
}
function assertValidConfig(
config: Partial<Config>
): asserts config is Config {
const required: (keyof Config)[] = ["apiUrl", "apiKey", "timeout"];
for (const key of required) {
if (config[key] === undefined) {
throw new Error(`Missing required config: ${key}`);
}
}
if (typeof config.timeout !== "number" || config.timeout <= 0) {
throw new Error("timeout must be a positive number");
}
}
function initApp(config: Partial<Config>) {
assertValidConfig(config);
// 이후 config는 Config — 모든 필드가 보장됨
fetch(config.apiUrl, {
headers: { Authorization: config.apiKey },
signal: AbortSignal.timeout(config.timeout),
});
}
실전 패턴: 타입 가드 조합
복잡한 타입을 검증할 때는 작은 타입 가드를 조합하는 것이 유지보수에 유리하다.
// 기본 블록
function isNonNull<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}
function hasProperty<K extends string>(
obj: unknown,
key: K
): obj is Record<K, unknown> {
return typeof obj === "object" && obj !== null && key in obj;
}
// 조합
interface Address {
street: string;
city: string;
zipCode: string;
}
interface Customer {
id: number;
name: string;
address: Address;
}
function isAddress(value: unknown): value is Address {
return (
hasProperty(value, "street") &&
hasProperty(value, "city") &&
hasProperty(value, "zipCode") &&
typeof value.street === "string" &&
typeof value.city === "string" &&
typeof value.zipCode === "string"
);
}
function isCustomer(value: unknown): value is Customer {
return (
hasProperty(value, "id") &&
hasProperty(value, "name") &&
hasProperty(value, "address") &&
typeof value.id === "number" &&
typeof value.name === "string" &&
isAddress(value.address) // 중첩 타입 가드
);
}
hasProperty는 속성 존재 여부를 확인하면서 동시에 Record<K, unknown> 타입으로 좁혀주는 유틸리티 가드다. 이걸 기반으로 isAddress, isCustomer를 쌓아올리면 각 가드가 작고 테스트하기 쉬워진다.
타입 가드 vs 대안들
| 방식 | 런타임 검증 | 컴파일 타입 안전 | 복잡한 스키마 |
|---|---|---|---|
as 단언 | ❌ | ❌ (거짓 안전) | - |
| 타입 가드 | ✅ | ✅ | 수동 작성 필요 |
| Zod | ✅ | ✅ | 스키마로 자동 |
| class-validator | ✅ | ✅ | 데코레이터 기반 |
타입 가드는 가볍고 의존성이 없다는 장점이 있다. 단순한 타입 구분이나 유니온 타입 좁히기에는 최적이다. 하지만 복잡한 객체 스키마를 검증해야 한다면 Zod 같은 스키마 검증 라이브러리가 더 실용적이다. Zod는 스키마 정의에서 타입과 가드를 모두 자동 생성하기 때문에 수동으로 타입 가드를 작성할 필요가 없다.
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
// 타입 자동 추론
type User = z.infer<typeof UserSchema>;
// 타입 가드 역할 — parse는 실패 시 예외, safeParse는 결과 객체
const result = UserSchema.safeParse(data);
if (result.success) {
// result.data는 User
}
정리
타입 가드의 핵심은 런타임 검사와 컴파일 타임 타입 정보를 연결하는 다리 역할이다.
typeof,instanceof,in— 기본적인 타입 구분에 사용- 타입 술어 (
value is Type) — 커스텀 검증 로직을 타입 시스템에 연결 - assertion 함수 (
asserts value is Type) — 전제 조건 검증 후 이후 코드 전체에 적용 - 판별 유니온을 먼저 고려하고, 불가능할 때 사용자 정의 타입 가드를 쓸 것
- 복잡한 스키마 검증에는 Zod 같은 라이브러리를 고려할 것
타입 가드는 TypeScript를 "더 안전한 JavaScript"로 만드는 핵심 도구다. as 단언으로 컴파일러를 속이는 대신, 타입 가드로 런타임과 컴파일 타임 모두에서 안전성을 확보하자.