Chapter 7

타입 좁히기 (Type Narrowing)

넓은 타입을 좁은 타입으로


타입 가드 (Type Guard) 판별 유니온 (Discriminated Union) 제어 흐름 분석

타입 좁히기란?

왜 이게 필요한가? TypeScript가 타입을 확실히 알 수 없을 때, 우리가 도와줘야 합니다. 예를 들어 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 주의점!

// typeof null === "object" (JavaScript의 역사적 버그!)
function process(value: string | null) {
  if (typeof value === "object") {
    // value는 여전히 null일 수 있음!
    // value.toUpperCase(); // 런타임 (Runtime, 실행 시점) 에러 가능!
  }
  if (value !== null) {
    value.toUpperCase(); // 안전!
  }
}
          

Truthiness (참/거짓) 좁히기

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[]  <-- 정확!
        

동등성 좁히기 (Equality Narrowing)

===, !==, ==, !=로 타입을 좁히기


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"
  }
}
        

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();
  }

  // 공통 메서드는 좁히기 없이 호출 가능
  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
}
        

타입 서술어 (Type Predicate)

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[]로 정확히 추론됨!
        

판별 유니온 (Discriminated Union)

공통 리터럴 프로퍼티(판별자)로 유니온 멤버를 구별하는 강력한 패턴. 각 타입에 "이름표"를 붙여놓고 그 이름표로 구분하는 방식입니다.


// 판별자(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 액션 / 상태 머신


// 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 접근 가능!
  }
}
        

완전성 검사 (Exhaustive Checking)

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 케이스를 추가하라는 알림!
          

왜 유용한가?

유니온 타입에 새 멤버를 추가할 때, 처리하지 않은 곳을 컴파일러가 자동으로 찾아줍니다. 대규모 코드베이스에서 누락을 방지하는 핵심 패턴!

단언 함수 (Assertion Functions)

조건이 거짓이면 에러를 던지고, 참이면 타입을 좁힘


// 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
}
        

제어 흐름 분석 (Control Flow Analysis)

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

Chapter 7 요약

핵심 정리

  • 타입 좁히기: 넓은 타입을 조건문으로 좁혀 안전하게 사용
  • typeof / instanceof / in: 내장 타입 가드
  • 타입 서술어 (is): 커스텀 타입 가드로 복잡한 조건 처리
  • 판별 유니온: 공통 리터럴 프로퍼티로 안전한 분기 처리
  • never + 완전성 검사: 모든 케이스 처리를 컴파일 타임에 보장
  • 단언 함수: 실패 시 에러, 성공 시 타입 좁히기
  • 제어 흐름 분석: TypeScript가 코드 경로를 추적하여 자동 추론

← 이전: Chapter 6 - 제네릭   |   다음 챕터: Chapter 8 - 고급 타입 →