넓은 타입을 좁은 타입으로
타입 가드 (Type Guard) 판별 유니온 (Discriminated Union) 제어 흐름 분석
string | number 타입인 값은 .toUpperCase()도 .toFixed()도 바로 쓸 수 없습니다. 어떤 타입인지 먼저 확인해야 합니다.
넓은 타입(유니온 등)을 조건문을 통해 더 구체적인 타입으로 추론하는 과정
function printValue(value: string | number) {
// 여기서 value는 string | number
if (typeof value === "string") {
// 이 블록 안에서 value는 string으로 좁혀짐!
console.log(value.toUpperCase()); // OK
console.log(value.padStart(10)); // OK
} else {
// 이 블록 안에서 value는 number로 좁혀짐!
console.log(value.toFixed(2)); // OK
console.log(value * 100); // OK
}
}
TypeScript 컴파일러는 제어 흐름 분석(Control Flow Analysis)을 통해 조건문 이후의 타입을 자동으로 좁힙니다. 개발자가 별도의 타입 단언 없이도 안전하게 코드 작성 가능!
typeof 타입 가드원시 타입 구별에 가장 기본적인 방법
// typeof가 반환하는 값들:
// "string" | "number" | "boolean" | "undefined"
// | "object" | "function" | "bigint" | "symbol"
function formatInput(input: string | number | boolean): string {
if (typeof input === "string") {
return input.trim().toLowerCase(); // string
}
if (typeof input === "number") {
return input.toLocaleString("ko-KR"); // number
}
return input ? "참" : "거짓"; // boolean
}
// typeof null === "object" (JavaScript의 역사적 버그!)
function process(value: string | null) {
if (typeof value === "object") {
// value는 여전히 null일 수 있음!
// value.toUpperCase(); // 런타임 (Runtime, 실행 시점) 에러 가능!
}
if (value !== null) {
value.toUpperCase(); // 안전!
}
}
falsy 값을 체크하여 타입을 좁히기
// JavaScript의 falsy 값들:
// 0, NaN, "" (빈 문자열), null, undefined, 0n (BigInt)
function printLength(str: string | null | undefined) {
if (str) {
// null, undefined, "" 모두 제거됨
console.log(str.length); // string (빈 문자열 아님)
}
}
// 실전: 선택적 값 처리
function getUserDisplayName(
firstName: string,
lastName?: string | null
): string {
if (lastName) {
return `${firstName} ${lastName}`;
}
return firstName;
}
// 배열 필터링에서 활용
const values: (string | null | undefined)[] =
["hello", null, "world", undefined, ""];
const filtered = values.filter(Boolean);
// 타입: (string | null | undefined)[] <-- 아직 좁혀지지 않음
// 타입을 정확히 좁히려면:
const filtered2 = values.filter(
(v): v is string => v != null && v !== ""
);
// 타입: string[] <-- 정확!
===, !==, ==, !=로 타입을 좁히기
function compare(a: string | number, b: string | boolean) {
if (a === b) {
// a와 b가 같으려면 공통 타입인 string이어야 함!
a.toUpperCase(); // OK: string
b.toUpperCase(); // OK: string
}
}
// null / undefined 체크
function processValue(value: string | null | undefined) {
// == null은 null과 undefined 둘 다 체크!
if (value == null) {
return; // value: null | undefined
}
// 여기서 value: string
console.log(value.toUpperCase());
}
// switch문도 동등성 좁히기!
function getLabel(status: "active" | "inactive" | "pending"): string {
switch (status) {
case "active":
return "활성"; // status: "active"
case "inactive":
return "비활성"; // status: "inactive"
case "pending":
return "대기중"; // status: "pending"
}
}
객체에 특정 프로퍼티가 있는지 확인하여 타입 좁히기
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();
}
// 공통 메서드는 좁히기 없이 호출 가능
animal.layEggs();
}
// 실전: API 에러 처리
interface SuccessResponse {
status: "ok";
data: any;
}
interface ErrorResponse {
status: "error";
error: string;
code: number;
}
function handleResponse(res: SuccessResponse | ErrorResponse) {
if ("error" in res) {
// res: ErrorResponse
console.error(`에러 ${res.code}: ${res.error}`);
} else {
// res: SuccessResponse
console.log("성공:", res.data);
}
}
instanceof 체크클래스 인스턴스 (Instance, 클래스로 만든 실제 객체) 여부를 판별하여 타입 좁히기
class HttpError 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 HttpError) {
// error: HttpError
console.log(`HTTP ${error.statusCode}: ${error.message}`);
} else if (error instanceof ValidationError) {
// error: ValidationError
console.log(`필드 '${error.field}' 검증 실패: ${error.message}`);
} else {
// error: Error (일반 에러)
console.log(`알 수 없는 에러: ${error.message}`);
}
}
// Date 활용 예제
function formatDate(value: string | Date): string {
if (value instanceof Date) {
return value.toLocaleDateString("ko-KR"); // Date
}
return new Date(value).toLocaleDateString("ko-KR"); // string
}
is 키워드로 커스텀 타입 가드 함수를 만들기. "이 함수가 true를 반환하면, 이 값은 반드시 이 타입이다"라고 TypeScript에게 알려주는 것입니다.
interface Cat {
name: string;
meow(): void;
}
interface Dog {
name: string;
bark(): void;
}
// 반환 타입이 "pet is Cat" - 타입 서술어!
// "이 함수가 true를 반환하면 pet은 Cat 타입이다"
function isCat(pet: Cat | Dog): pet is Cat {
return "meow" in pet; // meow 메서드가 있으면 Cat
}
function isDog(pet: Cat | Dog): pet is Dog {
return "bark" in pet;
}
function interact(pet: Cat | Dog) {
if (isCat(pet)) {
pet.meow(); // pet: Cat
} else {
pet.bark(); // pet: Dog
}
}
// 실전: 배열 필터링에서 타입 좁히기
interface AdminUser { role: "admin"; permissions: string[]; }
interface RegularUser { role: "user"; subscription: string; }
type AppUser = AdminUser | RegularUser;
function isAdmin(user: AppUser): user is AdminUser {
return user.role === "admin";
}
const users: AppUser[] = [
{ role: "admin", permissions: ["all"] },
{ role: "user", subscription: "free" },
{ role: "admin", permissions: ["read"] },
];
// filter + 타입 서술어 = 정확한 타입의 배열!
const admins: AdminUser[] = users.filter(isAdmin);
// admins 타입이 AdminUser[]로 정확히 추론됨!
공통 리터럴 프로퍼티(판별자)로 유니온 멤버를 구별하는 강력한 패턴. 각 타입에 "이름표"를 붙여놓고 그 이름표로 구분하는 방식입니다.
// 판별자(discriminant, 이름표 역할): kind 프로퍼티
interface Circle {
kind: "circle"; // 리터럴 타입 = 이 이름표로 구분!
radius: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Triangle {
kind: "triangle";
base: number;
height: number;
}
type Shape = Circle | Rectangle | Triangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
// shape: Circle
return Math.PI * shape.radius ** 2;
case "rectangle":
// shape: Rectangle
return shape.width * shape.height;
case "triangle":
// shape: Triangle
return (shape.base * shape.height) / 2;
}
}
// Redux 스타일 액션: type이 판별자(이름표) 역할
type Action =
| { type: "FETCH_START" } // 데이터 가져오기 시작
| { type: "FETCH_SUCCESS"; data: any[] } // 성공: data 포함
| { type: "FETCH_ERROR"; error: string } // 실패: error 포함
| { type: "RESET" }; // 초기화
interface State {
loading: boolean;
data: any[];
error: string | null;
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "FETCH_START":
return { ...state, loading: true, error: null };
case "FETCH_SUCCESS":
return { loading: false, data: action.data, error: null };
case "FETCH_ERROR":
return { loading: false, data: [], error: action.error };
case "RESET":
return { loading: false, data: [], error: null };
}
}
// 비동기 상태 표현 (로딩/성공/에러를 하나의 타입으로)
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function renderUser(state: AsyncState<User>) {
switch (state.status) {
case "idle": return "대기 중...";
case "loading": return "로딩 중...";
case "success": return `이름: ${state.data.name}`; // data 접근 가능!
case "error": return `오류: ${state.error}`; // error 접근 가능!
}
}
never 타입으로 모든 경우를 처리했는지 컴파일 타임 (Compile Time, 코드 변환 시점)에 검증
type Shape = Circle | Rectangle | Triangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// 모든 케이스를 처리했다면 여기 도달할 수 없음
// shape는 never 타입(존재할 수 없는 타입)이 됨
const _exhaustive: never = shape;
return _exhaustive;
}
}
// 새 타입을 추가하면?
interface Pentagon {
kind: "pentagon";
sideLength: number;
}
type Shape = Circle | Rectangle | Triangle | Pentagon;
// 이제 default에서 컴파일 에러 발생!
// Type 'Pentagon' is not assignable to type 'never'
// -> Pentagon 케이스를 추가하라는 알림!
유니온 타입에 새 멤버를 추가할 때, 처리하지 않은 곳을 컴파일러가 자동으로 찾아줍니다. 대규모 코드베이스에서 누락을 방지하는 핵심 패턴!
조건이 거짓이면 에러를 던지고, 참이면 타입을 좁힘
// asserts 키워드: "이 함수가 에러 없이 반환되면 조건이 참이다"
// 조건이 거짓이면 에러를 던져서 프로그램을 멈춤
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
// 에러가 안 났다면 value는 string이 확실!
}
function processInput(input: unknown) {
assertIsString(input); // 여기서 에러가 안 나면...
// 이 줄에 도달했다면 input은 반드시 string!
console.log(input.toUpperCase()); // OK: string으로 좁혀짐
}
// 실전: null 체크 단언
function assertNotNull<T>(
value: T | null | undefined,
message?: string
): asserts value is T {
if (value == null) {
throw new Error(message ?? "Value must not be null or undefined");
}
}
interface Config {
apiKey?: string;
dbUrl?: string;
}
function initApp(config: Config) {
assertNotNull(config.apiKey, "API 키가 필요합니다");
assertNotNull(config.dbUrl, "DB URL이 필요합니다");
// 여기서 apiKey와 dbUrl은 반드시 string!
connectDB(config.dbUrl); // OK: string
setupAPI(config.apiKey); // OK: string
}
TypeScript가 코드 흐름을 추적하여 자동으로 타입을 좁히는 과정
function example(value: string | number | null) {
// value: string | number | null
if (value === null) {
return; // 여기서 함수 종료 (early return)
}
// value: string | number (null 제거됨)
if (typeof value === "string") {
value; // string
return;
}
// value: number (string도 제거됨)
value; // number
}
// 할당에 의한 좁히기
function process() {
let value: string | number;
value = "hello";
value.toUpperCase(); // OK: string으로 좁혀짐
value = 42;
value.toFixed(); // OK: number로 좁혀짐
}
// 논리 연산자와 좁히기
function greet(name: string | null) {
// OR 연산자: null이면 기본값
const displayName = name || "익명";
// displayName: string
// Nullish coalescing: null/undefined만 체크
const safeName = name ?? "익명";
// safeName: string
}
| 기법 | 사용 대상 | 예시 |
|---|---|---|
typeof |
원시 타입 | typeof x === "string" |
instanceof |
클래스 인스턴스 | x instanceof Date |
in |
객체 프로퍼티 존재 | "fly" in animal |
| 동등성 | 리터럴 / null 비교 | x === null |
| Truthiness | falsy 값 제거 | if (x) { ... } |
| 타입 서술어 | 커스텀 타입 가드 | x is Cat |
| 판별 유니온 | 태그 기반 유니온 | switch (x.kind) |
| 단언 함수 | 에러 던지며 좁히기 | asserts x is T |
never |
완전성 검사 | const _: never = x |