if문 하나로 TypeScript가 똑똑해지는 이유

sumi-0011·6일 전

들어가며

function process(value: string | number) {
  console.log(value.toUpperCase());
  // ❌ Property 'toUpperCase' does not exist on type 'string | number'.
}

TypeScript를 처음 사용할 때 이 에러를 자주 만났습니다.

valuestring일 수도 있으니 toUpperCase()를 쓸 수 있어야 하는 것 아닌가? 하지만 TypeScript 입장에서는 valuenumber일 가능성도 있습니다. number에는 toUpperCase()가 없으니 에러를 내는 거죠.

그런데 if문 하나를 추가하면 에러가 사라집니다.

function process(value: string | number) {
  if (typeof value === 'string') {
    console.log(value.toUpperCase()); // ✅ OK!
  }
}

TypeScript가 if문 안에서는 valuestring이라는 걸 인식합니다. 별도의 타입 단언(as string) 없이도요.

이게 바로 Type Narrowing입니다. 이 개념을 이해하고 나면 TypeScript 코드 작성이 훨씬 수월해집니다.


Type Narrowing이란?

Type Narrowing은 넓은 타입을 좁은 타입으로 좁혀가는 과정입니다.

string | numberstring처럼, 가능한 타입의 범위를 줄여나가는 것이죠.

왜 이게 중요할까요?

TypeScript의 핵심 가치는 컴파일 타임에 에러를 잡는 것입니다. 그런데 Union 타입을 사용하면 "이 값이 정확히 어떤 타입인지 모른다"는 상황이 생깁니다.

function formatValue(value: string | number) {
  // value가 string인지 number인지 알 수 없으므로
  // 어떤 메서드도 안전하게 호출할 수 없습니다
}

Type Narrowing은 이 문제를 해결합니다. "이 시점에서 이 값은 확실히 string이다"라고 TypeScript에게 알려주는 것이죠.

TypeScript는 어떻게 타입을 추론할까요?

TypeScript는 코드의 흐름을 분석해서 타입을 자동으로 좁혀줍니다. 이 분석을 Control Flow Analysis(제어 흐름 분석)라고 부릅니다. (TypeScript 2.0부터 도입된 기능입니다.)

function greet(value: string | number) {
  // 여기서 value는 string | number

  if (typeof value === 'string') {
    // 이 블록에서 value는 string
    console.log(value.toUpperCase());
  } else {
    // 이 블록에서 value는 number
    console.log(value.toFixed(2));
  }
}

그리고 이런 분석을 가능하게 하는 조건문들을 Type Guard라고 합니다.

그럼 이제부터는 Type Guard를 실제 상황에서 어떻게 사용할 수 있을지 정리해보겠습니다.


Type Guard 총정리

1. typeof

원시 타입을 구분하는 가장 기본적인 방법입니다. 개인적으로 가장 자주 쓰는 Type Guard이기도 합니다.

function formatValue(value: string | number | boolean) {
  if (typeof value === 'string') {
    return value.trim(); // string
  }
  if (typeof value === 'number') {
    return value.toFixed(2); // number
  }
  return value ? 'Yes' : 'No'; // boolean
}

TypeScript는 typeof 연산자의 결과를 신뢰합니다. JavaScript 런타임에서 typeof가 반환하는 값은 확실하기 때문입니다. typeof value === 'string'true라면, 그 시점에서 value는 100% string입니다.

typeof가 반환하는 값들: "string", "number", "boolean", "undefined", "object", "function", "symbol", "bigint"

API 응답을 처리할 때 특히 유용합니다. 서버에서 숫자가 문자열로 올 때도 있고, 숫자 그대로 올 때도 있거든요.

function parseAmount(value: string | number): number {
  if (typeof value === 'string') {
    return parseFloat(value);
  }
  return value;
}

주의할 점: typeof null"object"를 반환합니다. JavaScript의 오래된 버그입니다. 따라서 null 체크는 typeof로 하면 안 됩니다.

2. Truthiness 체크

null이나 undefined를 걸러내는 가장 간단한 방법입니다. 코드도 짧고 직관적이라 자주 쓰게 됩니다.

function greet(name: string | null | undefined) {
  if (name) {
    console.log(`Hello, ${name}!`); // string
  }
}

JavaScript에서 nullundefined는 falsy 값입니다. 따라서 if (value)를 통과했다면, valuenullundefined도 아닌 것이 확실합니다. TypeScript는 이 JavaScript 동작을 알고 있어서 타입을 자동으로 좁혀줍니다.

아래는 React에서 Optional props를 처리할 때 자주 사용하는 패턴입니다.

function UserCard({ user }: { user?: User }) {
  if (user) {
    return <div>{user.name}</div>;
  }
  return <div>Loading...</div>;
}

배열에도 동일하게 적용됩니다.

function getLength(arr?: string[]) {
  if (arr) {
    return arr.length; // string[]
  }
  return 0;
}

그런데 여기서 함정이 하나 있습니다. 0, "", NaN도 falsy 값이라서 의도치 않게 걸러질 수 있습니다.

function goToPage(page: number | null) {
  if (page) {
    // ⚠️ page가 0이면 이 블록에 진입하지 않습니다
    navigate(`/list?page=${page}`);
  }

  // 명시적인 null 체크가 더 안전합니다
  if (page !== null) {
    // page가 0이어도 진입합니다
    navigate(`/list?page=${page}`);
  }
}

실제로 페이지네이션에서 첫 페이지(0)로 이동이 안 되는 버그를 만난 적이 있습니다. if (page) 조건 때문이었습니다. 그 이후로 숫자를 다룰 때는 !== null을 명시적으로 사용합니다.

3. 동등 비교 (===, !==)

Truthiness 체크는 "falsy가 아닌 모든 것"을 통과시킵니다. 반면 동등 비교는 정확히 그 값인지 확인합니다. null만 걸러내고 싶을 때, 0이나 ""는 살리고 싶을 때 동등 비교가 필요합니다.

function handle(value: string | null) {
  if (value === null) {
    return 'No value';
  }
  return value.toUpperCase(); // string
}

리터럴 타입과 함께 사용하면 더 강력해집니다.

type Status = 'loading' | 'success' | 'error';

function getMessage(status: Status) {
  if (status === 'loading') {
    return 'Loading...'; // 'loading'
  }
  if (status === 'success') {
    return 'Done!'; // 'success'
  }
  return 'Something went wrong'; // 'error'
}

API 호출 상태를 관리할 때 유용한 패턴입니다.

4. in 연산자

객체에 특정 프로퍼티가 있는지로 타입을 구분합니다.

interface AdminUser {
  role: 'admin';
  permissions: string[];
}

interface GuestUser {
  role: 'guest';
  expiresAt: Date;
}

function getUserInfo(user: AdminUser | GuestUser) {
  if ('permissions' in user) {
    console.log(`권한: ${user.permissions.join(', ')}`); // AdminUser
  } else {
    console.log(`만료일: ${user.expiresAt}`); // GuestUser
  }
}

API 응답 처리에서 성공/실패 응답의 구조가 다를 때 유용합니다.

interface SuccessResponse { data: User; }
interface ErrorResponse { error: string; }

function handleResponse(res: SuccessResponse | ErrorResponse) {
  if ('error' in res) {
    console.error(res.error); // ErrorResponse
    return;
  }
  console.log(res.data); // SuccessResponse
}

res.error로 바로 접근하면 안 되는 이유가 있습니다. SuccessResponse에는 error 프로퍼티가 정의되어 있지 않기 때문에 접근 자체가 타입 에러가 됩니다. in 연산자는 런타임에 프로퍼티 존재 여부를 체크하면서 TypeScript에게 타입 정보도 전달합니다.

5. instanceof

클래스로 만든 객체를 구분할 때 씁니다.

class ApiError extends Error {
  statusCode: number;
  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
  }
}

function handleError(error: Error) {
  if (error instanceof ApiError) {
    console.log(`Status: ${error.statusCode}`); // ApiError
  } else {
    console.log(error.message); // Error
  }
}

instanceof는 객체의 프로토타입 체인을 확인합니다. error instanceof ApiErrortrue라면, errorApiError 클래스(또는 그 하위 클래스)의 인스턴스입니다. TypeScript는 이를 통해 해당 클래스의 프로퍼티와 메서드에 안전하게 접근할 수 있다고 판단합니다.

커스텀 에러 클래스를 만들어서 에러 종류별로 다르게 처리할 수 있습니다.

class ValidationError extends Error {
  field: string;
  constructor(field: string, message: string) {
    super(message);
    this.field = field;
  }
}

class NetworkError extends Error {
  retryable: boolean;
  constructor(message: string, retryable: boolean) {
    super(message);
    this.retryable = retryable;
  }
}

function handleError(error: Error) {
  if (error instanceof ValidationError) {
    showFieldError(error.field, error.message);
  } else if (error instanceof NetworkError && error.retryable) {
    showRetryButton();
  } else {
    showGenericError(error.message);
  }
}

6. Array.isArray

typeof []"object"를 반환합니다. 그래서 배열인지 확인하려면 Array.isArray를 써야 합니다.

function process(input: string | string[]) {
  if (Array.isArray(input)) {
    return input.join(', '); // string[]
  }
  return input; // string
}

API 응답이 단일 객체일 수도 있고 배열일 수도 있을 때 유용합니다.

function normalizeResponse(data: User | User[]) {
  if (Array.isArray(data)) {
    return data; // User[]
  }
  return [data]; // User -> User[]
}

이렇게 정규화하면 이후 코드에서 항상 배열로 일관되게 처리할 수 있습니다.

7. Discriminated Union

복잡한 상태를 다룰 때 가장 권장하는 패턴입니다. 공통된 리터럴 프로퍼티(discriminant)로 타입을 구분합니다.

type ApiState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: string };

function render(state: ApiState) {
  switch (state.status) {
    case 'idle':
      return <div>Ready</div>;
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserCard user={state.data} />; // data 접근 가능
    case 'error':
      return <ErrorMessage message={state.error} />; // error 접근 가능
  }
}

이 패턴의 핵심 장점은 불가능한 상태 조합을 타입 레벨에서 방지한다는 것입니다.

아래와 같은 설계를 비교해보면 차이가 명확합니다.

// ❌ 문제가 있는 설계: 불가능한 상태 조합이 허용됨
interface State {
  isLoading: boolean;
  data: User | null;
  error: string | null;
}

// isLoading: true이면서 data와 error가 모두 존재하는 상태가 가능
// 이런 상태는 논리적으로 말이 안 됩니다

Discriminated Union을 사용하면 이런 논리적 오류를 컴파일 타임에 방지할 수 있습니다.

Redux나 Zustand의 액션 타입에서도 이 패턴이 널리 사용됩니다.

type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET'; payload: number };

function reducer(state: number, action: Action) {
  switch (action.type) {
    case 'INCREMENT': return state + 1;
    case 'DECREMENT': return state - 1;
    case 'SET': return action.payload; // payload 접근 가능
  }
}

사용자 정의 Type Guard (is 키워드)

기본 Type Guard들로 해결이 안 되는 경우가 있습니다. 복잡한 조건이 필요하거나, 조건을 재사용하고 싶을 때입니다.

이럴 때 is 키워드로 직접 Type Guard를 정의할 수 있습니다.

기본 문법

function isAdmin(user: AdminUser | GuestUser): user is AdminUser {
  return 'permissions' in user;
}

반환 타입이 user is AdminUser인 것이 핵심입니다. "이 함수가 true를 반환하면 userAdminUser 타입이다"라고 TypeScript에게 알려주는 것이죠.

단순히 boolean을 반환하는 것과의 차이를 살펴보겠습니다.

// ❌ boolean 반환: 타입이 좁혀지지 않음
function isAdminBoolean(user: AdminUser | GuestUser): boolean {
  return 'permissions' in user;
}

if (isAdminBoolean(user)) {
  console.log(user.permissions); // 에러: user는 여전히 AdminUser | GuestUser
}

// ✅ is 키워드 사용: 타입이 좁혀짐
function isAdmin(user: AdminUser | GuestUser): user is AdminUser {
  return 'permissions' in user;
}

if (isAdmin(user)) {
  console.log(user.permissions); // OK: user는 AdminUser
}

실무 예시: API 응답 체크

interface SuccessResponse {
  success: true;
  data: User;
}

interface ErrorResponse {
  success: false;
  error: string;
}

type ApiResponse = SuccessResponse | ErrorResponse;

// 사용자 정의 Type Guard
function isSuccess(res: ApiResponse): res is SuccessResponse {
  return res.success === true;
}

// 사용
function handleResponse(res: ApiResponse) {
  if (isSuccess(res)) {
    console.log(res.data); // SuccessResponse
  } else {
    console.error(res.error); // ErrorResponse
  }
}

이렇게 정의하면 여러 곳에서 isSuccess 함수를 재사용할 수 있습니다.

배열 필터링에서의 활용

Type Guard는 filter와 함께 사용할 때 특히 유용합니다.

const items: (string | null)[] = ['a', null, 'b', null, 'c'];

// ❌ 일반 filter: 타입이 (string | null)[]로 유지됨
const filtered1 = items.filter(item => item !== null);
// filtered1의 타입: (string | null)[]

// ✅ Type Guard 사용: 타입이 string[]로 좁혀짐
function isNotNull<T>(value: T | null): value is T {
  return value !== null;
}
const filtered2 = items.filter(isNotNull);
// filtered2의 타입: string[]

이 차이가 중요한 이유는, filtered1을 사용할 때마다 null 체크를 다시 해야 하기 때문입니다. filtered2는 그럴 필요가 없습니다.

실무에서는 이런 식으로 활용합니다.

// 비어있지 않은 문자열만 필터링
function isNonEmptyString(value: string | null | undefined): value is string {
  return value !== null && value !== undefined && value.length > 0;
}

const tags = ['react', '', null, 'typescript', undefined];
const validTags = tags.filter(isNonEmptyString); // string[]

어떤 Type Guard를 선택할까?

상황별로 정리하면 다음과 같습니다.

상황Type Guard
원시 타입(string, number 등) 구분typeof
null/undefined 체크Truthiness 또는 === null
숫자/문자열의 null 체크!== null (0, ""을 살리려면)
특정 값 체크=== 동등 비교
객체의 프로퍼티로 구분in 연산자
클래스 인스턴스 구분instanceof
배열 여부 확인Array.isArray
복잡한 상태 관리Discriminated Union
재사용 가능한 조건사용자 정의 Type Guard (is)

마치며

정리하면 다음과 같습니다.

  • Type Narrowing은 넓은 타입을 좁은 타입으로 좁혀가는 과정입니다
  • TypeScript는 Control Flow Analysis로 이를 자동으로 수행합니다
  • Type Guard는 이 분석을 가능하게 하는 조건문입니다
  • 상황에 맞는 Type Guard를 선택하면 타입 단언(as) 없이도 안전한 코드를 작성할 수 있습니다

Type Narrowing을 제대로 활용하면서 as 키워드 사용이 크게 줄었습니다. 이전에는 "TypeScript가 왜 이걸 모르지?"라며 as를 자주 사용했는데, 이제는 "내가 TypeScript에게 충분한 정보를 제공하지 않았구나"라고 생각하게 되었습니다.

그런데 한 가지 의문이 남습니다.

if (state.user) {
  setTimeout(() => {
    console.log(state.user.name); // ❌ 에러?!
  }, 100);
}

분명 if문에서 체크했는데, 왜 콜백 안에서는 에러가 발생할까요?

이 질문에 대한 답은 다음 글에서 다루겠습니다.

다음 글: TypeScript는 왜 내 코드를 의심할까


참고 자료

profile
안녕하세요 😚 썸네일을 쉽게 만들 수 있는 서비스를 운영중입니다. 많은 관심 부탁드립니다. https://thumbnail.ssumi.space/

0개의 댓글