본문으로 건너뛰기
Advertisement

6.2 타입 가드

타입 가드란?

타입스크립트는 정적 분석으로 타입을 추론하지만, 런타임에 실제 값의 종류를 확인해야 할 때가 있습니다. **타입 가드(Type Guard)**는 런타임 검사를 통해 특정 스코프 안에서 타입을 좁혀주는(narrow) 기법입니다.

타입 가드를 사용하면:

  • 유니온 타입에서 구체적인 타입을 꺼낼 수 있습니다.
  • unknown 타입 값을 안전하게 사용할 수 있습니다.
  • 외부 API 응답 데이터를 타입 안전하게 파싱할 수 있습니다.
타입 가드 = 런타임 검사 + 컴파일 타임 타입 좁히기

핵심 개념

1. typeof 타입 가드

typeof 연산자는 원시 타입(string, number, boolean, bigint, symbol, undefined, function)을 확인하는 데 사용합니다.

function processInput(input: string | number | boolean): string {
if (typeof input === 'string') {
// 이 블록에서 input은 string
return input.toUpperCase();
}
if (typeof input === 'number') {
// 이 블록에서 input은 number
return input.toFixed(2);
}
// 이 블록에서 input은 boolean
return input ? '참' : '거짓';
}

// typeof의 한계: 'object'는 null, 배열, 일반 객체 모두 포함
function checkObject(value: unknown): void {
if (typeof value === 'object') {
// value는 object | null — null 체크 필요!
if (value !== null) {
console.log(Object.keys(value));
}
}
}

2. instanceof 타입 가드

instanceof 연산자는 클래스(생성자 함수)의 인스턴스인지 확인합니다.

class Dog {
bark(): void { console.log('멍멍!'); }
}

class Cat {
meow(): void { console.log('야옹!'); }
}

function makeSound(animal: Dog | Cat): void {
if (animal instanceof Dog) {
animal.bark(); // Dog 타입으로 좁혀짐
} else {
animal.meow(); // Cat 타입으로 좁혀짐
}
}

// 에러 처리에서 자주 사용
function handleError(error: unknown): string {
if (error instanceof Error) {
return error.message; // Error 타입의 message 접근 가능
}
if (error instanceof TypeError) {
return `타입 오류: ${error.message}`;
}
return String(error);
}

3. in 연산자 타입 가드

in 연산자는 객체에 특정 프로퍼티가 존재하는지 확인합니다.

interface Fish {
swim(): void;
fins: number;
}

interface Bird {
fly(): void;
wings: number;
}

function move(animal: Fish | Bird): void {
if ('swim' in animal) {
// Fish 타입으로 좁혀짐
animal.swim();
} else {
// Bird 타입으로 좁혀짐
animal.fly();
}
}

// 옵셔널 프로퍼티와 함께
interface BasicUser {
id: number;
name: string;
}

interface AdminUser {
id: number;
name: string;
adminLevel: number;
permissions: string[];
}

function getPermissions(user: BasicUser | AdminUser): string[] {
if ('permissions' in user) {
return user.permissions; // AdminUser
}
return []; // BasicUser
}

4. 동등성(Equality) 타입 가드

===, !==, ==, != 비교로도 타입을 좁힐 수 있습니다.

type Direction = 'north' | 'south' | 'east' | 'west';
type ExtendedDirection = Direction | 'up' | 'down';

function isHorizontal(dir: ExtendedDirection): boolean {
if (dir === 'north' || dir === 'south') {
return false; // dir은 'north' | 'south'
}
if (dir === 'up' || dir === 'down') {
return false; // dir은 'up' | 'down'
}
return true; // dir은 'east' | 'west'
}

// null/undefined 동등성 가드
function processValue(value: string | null | undefined): string {
if (value == null) {
// null과 undefined 둘 다 걸러짐 (== 사용)
return '값 없음';
}
return value.trim(); // string으로 좁혀짐
}

5. 사용자 정의 타입 가드 (User-Defined Type Guards)

value is Type 형태의 반환 타입을 사용하여 커스텀 타입 가드를 정의합니다.

interface Cat {
kind: 'cat';
meow(): void;
}

interface Dog {
kind: 'dog';
bark(): void;
}

type Pet = Cat | Dog;

// 사용자 정의 타입 가드 — 반환 타입이 'value is Cat'
function isCat(pet: Pet): pet is Cat {
return pet.kind === 'cat';
}

function interact(pet: Pet): void {
if (isCat(pet)) {
pet.meow(); // Cat으로 좁혀짐
} else {
pet.bark(); // Dog으로 좁혀짐
}
}

// unknown 타입 검증
function isString(value: unknown): value is string {
return typeof value === 'string';
}

function isNumber(value: unknown): value is number {
return typeof value === 'number' && !isNaN(value);
}

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

6. Assertion Functions

asserts value is Type 형태로, 조건 불만족 시 예외를 던지는 함수입니다. 함수 호출 이후의 코드에서 타입이 좁혀집니다.

function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message);
}

function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new TypeError(`문자열이 아닙니다: ${typeof value}`);
}
}

function assertIsDefined<T>(value: T | null | undefined): asserts value is T {
if (value === null || value === undefined) {
throw new Error('값이 null 또는 undefined입니다.');
}
}

// 사용
function processConfig(config: unknown): void {
assertIsString(config); // 이 줄 이후 config는 string
console.log(config.toUpperCase()); // 안전하게 사용 가능
}

const maybeUser: User | null = getUser();
assertIsDefined(maybeUser); // 이후 maybeUser는 User
console.log(maybeUser.name); // null 체크 없이 사용 가능

코드 예제

예제 1: 복합 타입 가드 조합

interface Rectangle {
width: number;
height: number;
}

interface Circle {
radius: number;
}

interface Triangle {
base: number;
height: number;
}

type Shape = Rectangle | Circle | Triangle;

function isRectangle(shape: Shape): shape is Rectangle {
return 'width' in shape && 'height' in shape;
}

function isCircle(shape: Shape): shape is Circle {
return 'radius' in shape;
}

function isTriangle(shape: Shape): shape is Triangle {
return 'base' in shape && 'height' in shape;
}

function getArea(shape: Shape): number {
if (isCircle(shape)) return Math.PI * shape.radius ** 2;
if (isRectangle(shape)) return shape.width * shape.height;
if (isTriangle(shape)) return (shape.base * shape.height) / 2;
// 모든 케이스 처리됨 — TypeScript가 never로 추론
const _: never = shape;
return 0;
}

예제 2: 배열 타입 가드

// 배열 요소 타입 확인
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === 'string');
}

function isNumberArray(value: unknown): value is number[] {
return Array.isArray(value) && value.every(item => typeof item === 'number');
}

// 제네릭 배열 타입 가드 팩토리
function isArrayOf<T>(
guard: (item: unknown) => item is T
): (value: unknown) => value is T[] {
return (value): value is T[] =>
Array.isArray(value) && value.every(guard);
}

// 사용
const isUserArray = isArrayOf(
(item): item is User =>
isObject(item) &&
typeof (item as any).id === 'number' &&
typeof (item as any).name === 'string'
);

const data: unknown = [{ id: 1, name: '홍길동', email: 'hong@example.com' }];
if (isUserArray(data)) {
data.forEach(user => console.log(user.name)); // 안전하게 사용
}

실전 예제

실전 1: API 응답 파싱 타입 가드

interface ApiSuccess<T> {
status: 'success';
data: T;
timestamp: string;
}

interface ApiError {
status: 'error';
message: string;
code: number;
}

type ApiResponse<T> = ApiSuccess<T> | ApiError;

// 사용자 정의 타입 가드
function isApiSuccess<T>(response: ApiResponse<T>): response is ApiSuccess<T> {
return response.status === 'success';
}

function isApiError<T>(response: ApiResponse<T>): response is ApiError {
return response.status === 'error';
}

// 구조 검증 타입 가드
function isValidUser(value: unknown): value is User {
if (!isObject(value)) return false;
const obj = value as Record<string, unknown>;
return (
typeof obj.id === 'number' &&
typeof obj.name === 'string' &&
typeof obj.email === 'string' &&
obj.email.includes('@')
);
}

// API 클라이언트
async function fetchUser(id: number): Promise<User> {
const raw = await fetch(`/api/users/${id}`).then(r => r.json());

// unknown으로 받아서 타입 가드로 검증
const response = raw as ApiResponse<unknown>;

if (isApiError(response)) {
throw new Error(`API 오류 ${response.code}: ${response.message}`);
}

if (!isValidUser(response.data)) {
throw new TypeError('API가 올바르지 않은 사용자 데이터를 반환했습니다.');
}

return response.data; // User 타입으로 좁혀짐
}

// 배치 처리
async function fetchUsers(ids: number[]): Promise<User[]> {
const results = await Promise.allSettled(ids.map(fetchUser));

return results
.filter((r): r is PromiseFulfilledResult<User> => r.status === 'fulfilled')
.map(r => r.value);
}

실전 2: 폼 입력 검증 시스템

// 검증 결과 타입
interface ValidationSuccess {
valid: true;
value: string;
}

interface ValidationFailure {
valid: false;
errors: string[];
}

type ValidationResult = ValidationSuccess | ValidationFailure;

// 검증 함수 타입
type Validator = (value: string) => string | null; // null = 통과, string = 오류 메시지

// 검증기 팩토리
const validators = {
required: (label: string): Validator =>
(value) => value.trim() ? null : `${label}을(를) 입력해 주세요.`,

minLength: (min: number): Validator =>
(value) => value.length >= min ? null : `최소 ${min}자 이상 입력해 주세요.`,

maxLength: (max: number): Validator =>
(value) => value.length <= max ? null : `최대 ${max}자까지 입력 가능합니다.`,

email: (): Validator =>
(value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : '올바른 이메일 형식이 아닙니다.',

pattern: (regex: RegExp, message: string): Validator =>
(value) => regex.test(value) ? null : message,
};

function validate(value: string, rules: Validator[]): ValidationResult {
const errors = rules
.map(rule => rule(value))
.filter((error): error is string => error !== null); // 타입 가드 사용!

if (errors.length === 0) {
return { valid: true, value: value.trim() };
}
return { valid: false, errors };
}

// 타입 가드로 결과 확인
function isValid(result: ValidationResult): result is ValidationSuccess {
return result.valid;
}

// 폼 처리
interface LoginForm {
email: string;
password: string;
}

function processLoginForm(form: LoginForm): void {
const emailResult = validate(form.email, [
validators.required('이메일'),
validators.email(),
]);

const passwordResult = validate(form.password, [
validators.required('비밀번호'),
validators.minLength(8),
validators.maxLength(100),
]);

if (!isValid(emailResult)) {
console.error('이메일 오류:', emailResult.errors);
return;
}

if (!isValid(passwordResult)) {
console.error('비밀번호 오류:', passwordResult.errors);
return;
}

// 두 결과 모두 ValidationSuccess로 좁혀짐
console.log(`로그인 시도: ${emailResult.value}`);
}

실전 3: 플러그인 시스템

// 플러그인 인터페이스 계층
interface BasePlugin {
name: string;
version: string;
}

interface StoragePlugin extends BasePlugin {
type: 'storage';
read(key: string): Promise<string | null>;
write(key: string, value: string): Promise<void>;
}

interface AuthPlugin extends BasePlugin {
type: 'auth';
login(credentials: { username: string; password: string }): Promise<string>;
logout(token: string): Promise<void>;
verify(token: string): Promise<boolean>;
}

interface LogPlugin extends BasePlugin {
type: 'log';
log(level: 'info' | 'warn' | 'error', message: string): void;
getHistory(): string[];
}

type Plugin = StoragePlugin | AuthPlugin | LogPlugin;

// 타입 가드
function isStoragePlugin(plugin: Plugin): plugin is StoragePlugin {
return plugin.type === 'storage';
}

function isAuthPlugin(plugin: Plugin): plugin is AuthPlugin {
return plugin.type === 'auth';
}

function isLogPlugin(plugin: Plugin): plugin is LogPlugin {
return plugin.type === 'log';
}

// 플러그인 레지스트리
class PluginRegistry {
private plugins: Map<string, Plugin> = new Map();

register(plugin: Plugin): void {
this.plugins.set(plugin.name, plugin);
}

getStorage(): StoragePlugin | undefined {
for (const plugin of this.plugins.values()) {
if (isStoragePlugin(plugin)) return plugin;
}
return undefined;
}

getAuth(): AuthPlugin | undefined {
for (const plugin of this.plugins.values()) {
if (isAuthPlugin(plugin)) return plugin;
}
return undefined;
}

getLogger(): LogPlugin | undefined {
for (const plugin of this.plugins.values()) {
if (isLogPlugin(plugin)) return plugin;
}
return undefined;
}

async initialize(): Promise<void> {
const logger = this.getLogger();

for (const [name, plugin] of this.plugins) {
logger?.log('info', `플러그인 초기화: ${name} v${plugin.version}`);
}
}
}

고수 팁

팁 1: isNonNullable 패턴

// null과 undefined를 동시에 걸러내는 타입 가드
function isNonNullable<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}

// 사용: 배열에서 null 제거
const maybeUsers: (User | null | undefined)[] = [
{ id: 1, name: '홍길동', email: 'hong@test.com' },
null,
{ id: 2, name: '이순신', email: 'lee@test.com' },
undefined,
];

// filter의 타입이 자동으로 User[]로 추론됨
const users: User[] = maybeUsers.filter(isNonNullable);

팁 2: 타입 가드 함수 재사용 — 제네릭 활용

// 특정 키와 값 타입으로 객체를 검증하는 제네릭 가드
function hasProperty<K extends string>(
obj: unknown,
key: K
): obj is Record<K, unknown> {
return isObject(obj) && key in (obj as object);
}

function hasPropertyOfType<K extends string, V>(
obj: unknown,
key: K,
guard: (v: unknown) => v is V
): obj is Record<K, V> {
return hasProperty(obj, key) && guard((obj as any)[key]);
}

// 사용
function isProduct(value: unknown): value is { id: number; price: number; name: string } {
return (
hasPropertyOfType(value, 'id', isNumber) &&
hasPropertyOfType(value, 'price', isNumber) &&
hasPropertyOfType(value, 'name', isString)
);
}

팁 3: Assertion Functions와 Option 타입

// Option/Maybe 패턴과 assertion 결합
type Option<T> = T | null | undefined;

function assertSome<T>(
option: Option<T>,
message = '값이 존재하지 않습니다.'
): asserts option is T {
if (option === null || option === undefined) {
throw new Error(message);
}
}

function assertType<T>(
value: unknown,
guard: (v: unknown) => v is T,
message = '타입이 올바르지 않습니다.'
): asserts value is T {
if (!guard(value)) {
throw new TypeError(message);
}
}

// 사용 예시
function processEnvVar(name: string): string {
const value = process.env[name];
assertSome(value, `환경 변수 ${name}이(가) 설정되지 않았습니다.`);
return value; // string으로 좁혀짐
}

팁 4: 타입 가드와 필터 결합

// Promise.allSettled 결과 처리
async function settledResults<T>(promises: Promise<T>[]): Promise<{
fulfilled: T[];
rejected: Error[];
}> {
const results = await Promise.allSettled(promises);

const fulfilled = results
.filter((r): r is PromiseFulfilledResult<T> => r.status === 'fulfilled')
.map(r => r.value);

const rejected = results
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
.map(r => r.reason instanceof Error ? r.reason : new Error(String(r.reason)));

return { fulfilled, rejected };
}

정리 표

타입 가드문법대상 타입주의사항
typeoftypeof x === 'string'원시 타입typeof null === 'object' 주의
instanceofx instanceof Dog클래스 인스턴스프로토타입 체인 기반, 직렬화 후 깨짐
in'prop' in x객체 프로퍼티null/undefined에 적용 불가
동등성x === 'value'리터럴 타입== null 체크 관용구
사용자 정의value is Type 반환모든 타입본문 로직의 정확성은 개발자 책임
Assertionasserts value is Type모든 타입실패 시 반드시 throw 필요
isNonNullablevalue is T (null/undefined 제외)nullable 타입배열 filter와 자주 조합

다음 장에서는 **매핑 타입(6.3)**을 살펴봅니다. { [K in keyof T]: ... } 문법으로 기존 타입을 변환하는 강력한 기법과, Partial, Readonly, Pick 같은 유틸리티 타입을 직접 구현하는 방법을 학습합니다.

Advertisement