목록으로

프로그래밍 · TypeScript

TypeScript 완전정복 18화: 조건문을 지나면 타입이 달라진다

BeanCon
TypeScript 조건문과 typeof를 이용해 유니언 타입을 안전하게 좁히는 방법을 설명하는 대표 이미지

TypeScript 완전정복 18화입니다. 조건문을 지나며 타입이 달라지는 타입 좁히기와 typeof 사용법을 정리했습니다. 유니언 타입, typeof 검사, null과 undefined 처리, truthiness, 리터럴 비교, switch, 조기 반환, 제어 흐름 분석, 논리 연산자, unknown 좁히기, 실무 함수와 실습 문제까지 다룹니다.

목차

타입 좁히기와 typeof 완벽 이해하기

지금까지 우리는 변수와 함수에게 타입이라는 명찰을 달아주었습니다.

let userName: string = "김타입";
let age: number = 25;

유니언 타입을 사용하면 하나의 값이 여러 타입 중 하나일 수도 있었습니다.

let userId: string | number;

userId = 100;
userId = "USER-100";

그런데 문제가 하나 있습니다.

다음 함수를 살펴보겠습니다.

function printValue(
  value: string | number
): void {
  console.log(
    value.toUpperCase()
  );
}

TypeScript가 오류를 표시합니다.

왜일까요?

`value`가 문자열이라면 `toUpperCase()`를 사용할 수 있습니다.

"typescript".toUpperCase();

하지만 숫자라면 사용할 수 없습니다.

100.toUpperCase();

TypeScript 입장에서는 함수 안으로 무엇이 들어올지 모릅니다.

value

가능성 1
→ string

가능성 2
→ number

그래서 함부로 문자열 전용 기능을 허락하지 않습니다.

그런데 다음처럼 조건문을 추가하면 상황이 달라집니다.

function printValue(
  value: string | number
): void {
  if (
    typeof value === "string"
  ) {
    console.log(
      value.toUpperCase()
    );
  }
}

놀랍게도 오류가 사라집니다.

`if`문 안에서 TypeScript는 `value`의 타입을 다음처럼 판단합니다.

조건문 밖

string | number

↓ typeof 검사

조건문 안

string

TypeScript가 조건문을 읽고 가능성을 하나 제거했습니다.

이것을 타입 좁히기(Type Narrowing)라고 합니다.

지금부터 TypeScript는 타입 검사관을 잠시 내려놓고 탐정 모자를 씁니다. 🕵️

“`typeof value === "string"`이라고? 그렇다면 이 구역에서 범인은 string이군.”

이번 시간에는 TypeScript가 코드의 흐름을 따라가며 타입을 어떻게 추리하는지 살펴보겠습니다.

1. 이번 시간에 배울 내용

이번 시간에는 다음 내용을 알아봅니다.

  • 타입 좁히기란 무엇인가?
  • 왜 유니언 타입은 바로 사용할 수 없는가?
  • `typeof`를 이용한 타입 좁히기
  • 문자열, 숫자, 불리언 좁히기
  • `typeof`가 반환하는 값
  • `typeof null`의 함정
  • `null`과 `undefined`를 안전하게 확인하는 방법
  • truthiness를 이용한 타입 좁히기
  • truthiness 검사의 함정
  • `===`, `!==`를 이용한 타입 좁히기
  • 리터럴 타입 좁히기
  • `switch`를 이용한 상태 분기
  • 조건문의 반대쪽 타입은 어떻게 좁혀지는가?
  • 조기 반환과 타입 좁히기
  • 제어 흐름 분석이란 무엇인가?
  • 변수 재할당 후 타입은 어떻게 달라지는가?
  • 논리 연산자를 이용한 좁히기
  • 함수 내부에서 타입이 어떻게 추적되는가?
  • 과도한 타입 단언 대신 좁히기를 사용하는 이유
  • 실무에서 타입 좁히기를 설계하는 방법

오늘의 핵심 문장은 다음과 같습니다.

타입 좁히기는 여러 가능성 중 현재 코드에서 가능한 타입만 남기는 과정이다.

2. 타입 좁히기란?

다음 변수를 보겠습니다.

let value:
  string | number;

현재 TypeScript가 알고 있는 정보는 다음과 같습니다.

value는

string일 수도 있고
number일 수도 있다.

이 상태에서는 두 타입 모두에서 사용할 수 있는 기능만 안전합니다.

예를 들어 문자열과 숫자 모두 `toString()`을 가지고 있습니다.

function printValue(
  value: string | number
): void {
  console.log(
    value.toString()
  );
}

하지만 문자열 전용 메서드는 사용할 수 없습니다.

value.toUpperCase();

숫자 전용 메서드도 바로 사용할 수 없습니다.

value.toFixed(2);

먼저 타입을 구분해야 합니다.

if (
  typeof value === "string"
) {
  value.toUpperCase();
}

이 과정이 바로 타입 좁히기입니다.

3. 왜 이름이 Narrowing일까?

`narrow`는 영어로 좁히다라는 뜻입니다.

처음 타입의 범위:

string | number

조건을 통과합니다.

typeof value === "string"

가능한 타입이 줄어듭니다.

string

즉,

넓은 타입

string | number

↓

조건 검사

↓

좁은 타입

string

입니다.

유니언 타입이라는 넓은 도로에서 검문소를 통과하며 차선을 하나로 줄이는 느낌입니다. 🚧

4. 유니언 타입에서는 공통 기능만 바로 사용할 수 있다

다음 함수를 보겠습니다.

function processValue(
  value: string | number
): void {
  console.log(
    value.toString()
  );
}

`toString()`은 문자열과 숫자 모두 사용할 수 있습니다.

따라서 문제없습니다.

하지만 다음은 오류입니다.

function processValue(
  value: string | number
): void {
  console.log(
    value.toUpperCase()
  );
}

왜냐하면 `number`에는 `toUpperCase()`가 없기 때문입니다.

반대로 다음도 오류입니다.

function processValue(
  value: string | number
): void {
  console.log(
    value.toFixed(2)
  );
}

`string`에는 `toFixed()`가 없습니다.

정리하면:

유니언 타입

A | B

↓

타입 좁히기 전

A와 B가 공통으로
안전하게 사용할 수 있는 기능만 허용

5. 가장 기본적인 타입 좁히기 `typeof`

JavaScript의 `typeof` 연산자를 이용할 수 있습니다.

function printValue(
  value: string | number
): void {
  if (
    typeof value === "string"
  ) {
    console.log(
      value.toUpperCase()
    );
  }
}

조건문 안에서 `value`는 `string`입니다.

value.toUpperCase();

숫자인 경우도 처리해 보겠습니다.

function printValue(
  value: string | number
): void {
  if (
    typeof value === "string"
  ) {
    console.log(
      value.toUpperCase()
    );
  } else {
    console.log(
      value.toFixed(2)
    );
  }
}

TypeScript는 `else`에서도 추론합니다.

if 영역
typeof value === "string"

→ string

else 영역

string이 아님
원래 타입은 string | number

→ number

한쪽 용의자가 무죄로 밝혀지면 남은 용의자가 자연스럽게 범인이 됩니다.

6. `typeof`는 JavaScript 기능이다

중요한 점이 있습니다.

`typeof`는 TypeScript만의 문법이 아닙니다.

JavaScript에서 원래 제공하는 연산자입니다.

console.log(
  typeof "hello"
);

결과:

string
console.log(
  typeof 100
);

결과:

number

TypeScript는 JavaScript의 런타임 검사를 보고 타입 정보를 좁히는 것입니다.

즉:

JavaScript
→ 실제 값을 검사

TypeScript
→ 그 검사 결과를 이용해 타입 추론

둘이 공동 수사 중입니다. 🔍

7. `typeof`로 확인할 수 있는 대표적인 값

다음 값을 살펴보겠습니다.

typeof "hello"

결과:

string
typeof 100

결과:

number
typeof true

결과:

boolean
typeof undefined

결과:

undefined
typeof 123n

결과:

bigint
typeof Symbol()

결과:

symbol
typeof (() => {})

결과:

function

일반 객체는:

typeof {}

결과:

object

8. 문자열 타입 좁히기

사용자 입력이 문자열 또는 숫자로 들어올 수 있다고 가정해 보겠습니다.

function formatInput(
  input: string | number
): string {
  if (
    typeof input === "string"
  ) {
    return input.trim();
  }

  return input.toFixed(2);
}

문자열 영역:

input.trim();

가능합니다.

숫자 영역:

input.toFixed(2);

가능합니다.

TypeScript가 각 코드 블록 안에서 정확한 타입을 알고 있기 때문입니다.

9. 숫자 타입 좁히기

숫자를 먼저 검사해도 됩니다.

function formatValue(
  value: string | number
): string {
  if (
    typeof value === "number"
  ) {
    return value.toFixed(2);
  }

  return value.toUpperCase();
}

`if` 내부:

number

`if`를 통과하지 않은 이후:

string

굳이 `else`를 작성하지 않아도 됩니다.

왜 가능한지는 잠시 후 조기 반환에서 자세히 알아보겠습니다.

10. 불리언 타입 좁히기

여러 타입이 포함된 유니언도 가능합니다.

function processValue(
  value:
    | string
    | number
    | boolean
): void {
  if (
    typeof value === "boolean"
  ) {
    console.log(
      value
        ? "참입니다."
        : "거짓입니다."
    );

    return;
  }

  console.log(value);
}

조건문 안에서는:

boolean

조건문 이후에는:

string | number

한 종류의 타입이 제거되었습니다.

11. 여러 번 좁힐 수 있다

타입 후보가 여러 개라면 순서대로 좁힐 수 있습니다.

function processValue(
  value:
    | string
    | number
    | boolean
): void {
  if (
    typeof value === "string"
  ) {
    console.log(
      value.toUpperCase()
    );

    return;
  }

  if (
    typeof value === "number"
  ) {
    console.log(
      value.toFixed(2)
    );

    return;
  }

  console.log(
    value
      ? "TRUE"
      : "FALSE"
  );
}

타입 변화:

처음

string
| number
| boolean

↓

첫 번째 if 통과 안 함

number
| boolean

↓

두 번째 if 통과 안 함

boolean

TypeScript가 용의자를 한 명씩 조사실에서 내보내고 있습니다.

12. `typeof null`이라는 오래된 함정

여기서 JavaScript의 유명한 함정이 등장합니다.

console.log(
  typeof null
);

무엇이 출력될까요?

많은 초보자는 다음을 예상합니다.

null

하지만 실제 결과는:

object

입니다.

typeof null === "object"

결과:

true

이것은 JavaScript 초기 설계에서 생긴 오래된 역사적 특성입니다.

그리고 너무 많은 기존 코드가 이 동작에 의존하게 되어 지금까지 유지되고 있습니다.

JavaScript 박물관에 전시해야 할 유물인데 아직 현역으로 출근하고 있습니다. 🏺

13. `typeof value === "object"`만 검사하면 위험하다

다음 타입이 있다고 하겠습니다.

type User = {
  name: string;
};

function printUser(
  value:
    User | null
): void {
  if (
    typeof value === "object"
  ) {
    console.log(
      value.name
    );
  }
}

문제가 있습니다.

`null`도 `typeof` 결과가 `"object"`입니다.

따라서 TypeScript는 `value`가 `null`일 가능성을 완전히 제거할 수 없습니다.

안전하게 확인하려면:

function printUser(
  value:
    User | null
): void {
  if (
    value !== null &&
    typeof value === "object"
  ) {
    console.log(
      value.name
    );
  }
}

이제 조건문 안에서 `value`는 `User`입니다.

14. `null`은 직접 비교하는 것이 명확하다

다음 타입이 있습니다.

function printName(
  name:
    string | null
): void {
  if (
    name !== null
  ) {
    console.log(
      name.toUpperCase()
    );
  }
}

조건문 내부:

string

반대쪽:

null

다음처럼 작성해도 됩니다.

if (
  name === null
) {
  console.log(
    "이름이 없습니다."
  );
} else {
  console.log(
    name.toUpperCase()
  );
}

`null`은 명시적으로 비교하면 의도가 분명합니다.

15. `undefined` 좁히기

선택적 값에서 `undefined`가 자주 등장합니다.

function printNickname(
  nickname:
    string | undefined
): void {
  if (
    nickname !== undefined
  ) {
    console.log(
      nickname.toUpperCase()
    );
  }
}

조건문 내부에서는:

string

입니다.

`typeof`를 사용할 수도 있습니다.

if (
  typeof nickname ===
  "string"
) {
  console.log(
    nickname.toUpperCase()
  );
}

16. `null`과 `undefined`를 함께 처리하기

다음 타입을 생각해 보겠습니다.

type UserName =
  string
  | null
  | undefined;

둘 다 제거하고 싶습니다.

명시적으로 검사할 수 있습니다.

function printName(
  name: UserName
): void {
  if (
    name !== null &&
    name !== undefined
  ) {
    console.log(
      name.toUpperCase()
    );
  }
}

조건문 안에서는:

string

입니다.

17. `value != null` 패턴

JavaScript의 느슨한 동등 비교를 의도적으로 사용하는 패턴도 있습니다.

if (
  value != null
) {
  // null과 undefined 모두 제외
}

왜 가능할까요?

JavaScript에서:

null == undefined

결과는:

true

따라서:

value != null

은 `null`과 `undefined` 둘 다 걸러냅니다.

다만 일반적인 비교에서는 `===`, `!==`를 사용하는 것이 더 명확합니다.

이 패턴은 `null`과 `undefined`를 동시에 확인하려는 의도가 분명할 때 제한적으로 사용합니다.

18. Truthiness를 이용한 타입 좁히기

JavaScript에서는 조건문에 값을 직접 넣을 수 있습니다.

if (value) {
  // ...
}

JavaScript는 값을 참 또는 거짓으로 평가합니다.

거짓으로 평가되는 대표적인 값은:

false
0
""
null
undefined
NaN

입니다.

이를 falsy 값이라고 합니다.

그 외 많은 값은 truthy입니다.

TypeScript는 이런 조건문도 타입 좁히기에 활용합니다.

19. 문자열 또는 null에서 truthiness 사용하기

다음 함수를 보겠습니다.

function printName(
  name:
    string | null
): void {
  if (name) {
    console.log(
      name.toUpperCase()
    );
  }
}

`if` 안에서 `name`은 문자열로 좁혀집니다.

`null`은 falsy이기 때문입니다.

겉으로 보기에는 편리합니다.

하지만 주의해야 할 점이 있습니다.

20. 빈 문자열도 falsy다

문자열에는 빈 문자열도 있습니다.

const name = "";

빈 문자열은 falsy입니다.

Boolean("")

결과:

false

따라서 다음 코드는 `null`뿐 아니라 `""`도 걸러냅니다.

if (name) {
  // ...
}

만약 빈 문자열이 유효한 값이라면 문제가 될 수 있습니다.

function printMessage(
  message:
    string | null
): void {
  if (message) {
    console.log(message);
  } else {
    console.log(
      "메시지가 없습니다."
    );
  }
}

`message`가 `""`여도 “메시지가 없습니다”로 처리됩니다.

의도한 동작인지 확인해야 합니다.

21. 숫자 `0`도 falsy다

다음 함수가 있다고 해보겠습니다.

function printScore(
  score:
    number | null
): void {
  if (score) {
    console.log(
      `${score}점`
    );
  } else {
    console.log(
      "점수가 없습니다."
    );
  }
}

문제가 있습니다.

0점은 정상적인 점수일 수 있습니다.

하지만:

Boolean(0)

은:

false

입니다.

따라서 `score === 0`도 “점수가 없습니다”로 처리됩니다.

이럴 때는 명확하게 비교합니다.

if (
  score !== null
) {
  console.log(
    `${score}점`
  );
}

22. truthiness는 편리하지만 의미를 먼저 생각하자

다음 상황이라면 truthiness가 적절할 수 있습니다.

if (userName) {
  // 빈 문자열도 유효하지 않음
}

반대로 다음 값들이 정상일 수 있다면 주의해야 합니다.

0
""
false

예:

function printSetting(
  value:
    string
    | number
    | boolean
    | null
): void {
  if (value) {
    console.log(value);
  }
}

여기서는 다음 정상 값도 제거됩니다.

0
""
false

따라서 무엇을 제외하려는지 정확하게 생각해야 합니다.

23. `Boolean()`은 타입 좁히기에 항상 같은 효과를 주지 않는다

다음 코드를 생각해 보겠습니다.

if (
  Boolean(value)
) {
  // ...
}

실행 중에는 truthy/falsy 검사를 수행합니다.

하지만 TypeScript가 `if (value)`와 똑같은 수준으로 타입을 좁혀주지 않는 경우가 있습니다.

타입 좁히기가 목적이라면 단순한 조건 표현이 더 명확합니다.

if (value) {
  // ...
}

또는 정확한 조건을 작성합니다.

if (
  value !== null
) {
  // ...
}

타입 검사기에게도 암호문보다 직접적인 증언이 좋습니다.

24. `===` 비교를 이용한 좁히기

리터럴 타입에서도 동등 비교가 매우 강력합니다.

type Status =
  | "idle"
  | "loading"
  | "success"
  | "error";

함수를 만들어 보겠습니다.

function printStatus(
  status: Status
): void {
  if (
    status === "loading"
  ) {
    console.log(
      "불러오는 중입니다."
    );
  }
}

조건문 안에서 `status`의 타입은 정확히:

"loading"

입니다.

25. `else`에서는 해당 리터럴이 제거된다

다음 코드를 살펴보겠습니다.

function printStatus(
  status: Status
): void {
  if (
    status === "loading"
  ) {
    console.log(
      "로딩 중"
    );
  } else {
    console.log(status);
  }
}

`else`에서 가능한 타입은:

"idle"
| "success"
| "error"

입니다.

`"loading"`은 이미 제외되었습니다.

TypeScript는 단순히 “문자열이다” 정도만 추론하는 것이 아닙니다.

리터럴 수준으로 가능성을 추적합니다.

26. 여러 리터럴을 순서대로 좁히기

function getStatusMessage(
  status: Status
): string {
  if (
    status === "idle"
  ) {
    return "대기 중";
  }

  if (
    status === "loading"
  ) {
    return "불러오는 중";
  }

  if (
    status === "success"
  ) {
    return "성공";
  }

  return "오류";
}

마지막까지 도달했다면 가능한 타입은:

"error"

뿐입니다.

왜냐하면:

idle 제거
loading 제거
success 제거

남은 값
→ error

이기 때문입니다.

TypeScript의 추리가 상당히 집요합니다.

27. `switch`로 리터럴 타입 좁히기

상태가 많다면 `switch`가 읽기 좋을 수 있습니다.

function getStatusMessage(
  status: Status
): string {
  switch (status) {
    case "idle":
      return "대기 중";

    case "loading":
      return "불러오는 중";

    case "success":
      return "성공";

    case "error":
      return "오류";
  }
}

각 `case` 안에서 `status`는 해당 리터럴 타입으로 좁혀집니다.

case "idle"
→ status: "idle"

case "loading"
→ status: "loading"

case "success"
→ status: "success"

상태 기반 코드를 작성할 때 매우 유용합니다.

28. 두 변수의 동등 비교도 타입을 좁힐 수 있다

다음 함수를 살펴보겠습니다.

function compareValues(
  first:
    string | number,
  second:
    string | boolean
): void {
  if (
    first === second
  ) {
    console.log(
      first.toUpperCase()
    );
  }
}

왜 `toUpperCase()`가 가능할까요?

두 타입을 비교해 보겠습니다.

first
→ string | number

second
→ string | boolean

두 값이 `===`로 같으려면 공통으로 가능한 타입은:

string

입니다.

따라서 조건문 안에서 TypeScript는 두 값을 모두 문자열로 좁힐 수 있습니다.

first.toUpperCase();
second.toUpperCase();

타입 수사팀이 교집합까지 계산합니다. 🧮

29. 부정 조건으로도 좁힐 수 있다

다음 코드도 가능합니다.

function printValue(
  value:
    string | number
): void {
  if (
    typeof value !==
    "string"
  ) {
    console.log(
      value.toFixed(2)
    );

    return;
  }

  console.log(
    value.toUpperCase()
  );
}

첫 번째 조건:

string이 아니다

원래 타입:

string | number

그러므로:

number

입니다.

반대 조건도 타입 좁히기에 활용됩니다.

30. 조기 반환과 타입 좁히기

다음 코드를 보겠습니다.

function formatValue(
  value:
    string | number
): string {
  if (
    typeof value ===
    "number"
  ) {
    return value.toFixed(2);
  }

  return value.toUpperCase();
}

두 번째 `return`에서 왜 `value`가 문자열일까요?

첫 번째 조건에서 숫자라면 함수가 이미 끝납니다.

if (
  typeof value === "number"
) {
  return;
}

따라서 그 아래 코드까지 도달했다는 것은:

number가 아니다

라는 뜻입니다.

원래 타입이:

string | number

이므로 남는 것은:

string

입니다.

이것이 제어 흐름 분석(Control Flow Analysis)의 대표적인 예입니다.

31. Guard Clause 패턴

실무에서는 잘못된 경우를 먼저 반환하는 형태를 자주 사용합니다.

function printUserName(
  name:
    string | null
): void {
  if (
    name === null
  ) {
    console.log(
      "이름이 없습니다."
    );

    return;
  }

  console.log(
    name.toUpperCase()
  );
}

`return` 아래에서 `name`은 `string`입니다.

이 방식은 중첩을 줄여줍니다.

다음 코드보다:

if (
  name !== null
) {
  console.log(
    name.toUpperCase()
  );
}

함수가 복잡할 경우 조기 반환 방식이 읽기 좋아질 수 있습니다.

if (
  name === null
) {
  return;
}

console.log(
  name.toUpperCase()
);

문 앞에서 신분 확인이 끝났으니 건물 안에서는 계속 신분증을 보여줄 필요가 없습니다.

32. 여러 조건을 조기 반환으로 제거하기

function processValue(
  value:
    | string
    | number
    | null
): string {
  if (
    value === null
  ) {
    return "값 없음";
  }

  if (
    typeof value ===
    "number"
  ) {
    return value.toFixed(2);
  }

  return value.toUpperCase();
}

타입 변화:

처음

string
| number
| null

↓

null 반환

string
| number

↓

number 반환

string

마지막 줄에서는 문자열만 남습니다.

33. 제어 흐름 분석이란?

TypeScript는 단순히 한 줄짜리 조건만 보는 것이 아닙니다.

코드가 어떤 경로로 실행될 수 있는지 분석합니다.

예를 들어:

function example(
  value:
    string | number
): void {
  if (
    typeof value ===
    "string"
  ) {
    console.log(
      value.toUpperCase()
    );
  } else {
    console.log(
      value.toFixed(2)
    );
  }
}

TypeScript는 두 실행 경로를 분석합니다.

경로 1

typeof value === "string"
→ string

경로 2

string이 아님
→ number

이것을 제어 흐름 분석이라고 합니다.

34. 타입은 코드 위치에 따라 달라질 수 있다

같은 변수인데도 코드 위치마다 타입이 다를 수 있습니다.

function example(
  value:
    string | number
): void {
  // 여기:
  // string | number

  if (
    typeof value ===
    "string"
  ) {
    // 여기:
    // string

    console.log(
      value.toUpperCase()
    );
  }

  // 여기:
  // 다시 string | number
}

조건문을 빠져나오면 숫자인 경우도 다시 가능해집니다.

왜냐하면 문자열이 아닐 때도 함수 실행이 계속되기 때문입니다.

if 내부
→ 조건을 만족한 경로만 존재

if 이후
→ 조건을 만족한 경로와
   만족하지 않은 경로가 다시 합쳐짐

TypeScript의 타입은 변수에 영구 문신처럼 새겨지는 것이 아닙니다.

현재 위치에서 확보된 증거에 따라 달라집니다.

35. `else`를 사용하면 좁힌 상태가 유지된다

function example(
  value:
    string | number
): void {
  if (
    typeof value ===
    "string"
  ) {
    value.toUpperCase();
  } else {
    value.toFixed(2);
  }
}

`if`와 `else`는 서로 배타적인 경로입니다.

따라서 각각 정확한 타입을 유지할 수 있습니다.

36. 재할당하면 타입 흐름도 바뀐다

다음 코드를 살펴보겠습니다.

let value:
  string | number =
    "TypeScript";

현재 값은 문자열이므로 TypeScript가 더 구체적으로 알고 있을 수 있습니다.

console.log(
  value.toUpperCase()
);

이후 숫자를 할당합니다.

value = 100;

이제 해당 위치에서 값은 숫자입니다.

console.log(
  value.toFixed(2)
);

TypeScript는 변수의 선언 타입뿐 아니라 할당 흐름도 분석합니다.

선언 타입
string | number

현재 값
"TypeScript"
→ string

재할당
100
→ number

37. 하지만 선언된 타입 범위를 벗어날 수는 없다

다음 변수는:

let value:
  string | number;

문자열과 숫자를 받을 수 있습니다.

value = "hello";
value = 100;

하지만 불리언은 안 됩니다.

value = true;

현재 흐름에서 타입이 좁혀질 수는 있어도 원래 선언된 범위를 넘어설 수는 없습니다.

선언 타입
→ 경기장 경계

타입 좁히기
→ 경기장 안에서 현재 서 있는 위치

관중석까지 뛰어갈 수는 없습니다.

38. 할당 후 다시 넓어질 수도 있다

function example(
  input:
    string | number
): void {
  let value = input;

  if (
    typeof value ===
    "string"
  ) {
    console.log(
      value.toUpperCase()
    );

    value = 100;

    console.log(
      value.toFixed(2)
    );
  }
}

처음 `if` 안에서는 문자열이었습니다.

그런데 숫자를 재할당했습니다.

value = 100;

이후에는 숫자로 분석됩니다.

타입 좁히기는 변수를 영구적으로 바꾸는 기능이 아닙니다.

현재 값과 코드 흐름을 따라 계속 업데이트됩니다.

39. 논리 AND `&&`를 이용한 좁히기

다음 타입이 있습니다.

type User = {
  name: string;
};

function printUser(
  user:
    User | null
): void {
  if (
    user &&
    user.name
  ) {
    console.log(
      user.name.toUpperCase()
    );
  }
}

`user &&`를 통과했다는 것은 `user`가 `null`이 아니라는 의미입니다.

따라서 뒤쪽 표현식에서 `user`를 객체로 사용할 수 있습니다.

다만 `user.name`이 빈 문자열이면 조건 전체는 false가 된다는 점도 기억해야 합니다.

40. 조건 순서가 중요하다

다음처럼 작성하면 문제가 있습니다.

if (
  user.name &&
  user
) {
  // ...
}

`user.name`을 읽는 순간 아직 `user`가 `null`일 가능성이 있습니다.

안전한 검사가 먼저 와야 합니다.

if (
  user &&
  user.name
) {
  // ...
}

JavaScript의 `&&`는 왼쪽부터 평가합니다.

1. user 확인
2. user가 truthy일 때만
3. user.name 확인

안전문부터 열고 안쪽 문을 여는 순서입니다.

41. 선택적 연결 연산자 `?.`와 타입 안전성

다음 값이 있습니다.

interface User {
  profile?: {
    nickname?: string;
  };
}

const user:
  User | null = null;

선택적 연결 연산자로 안전하게 접근할 수 있습니다.

const nickname =
  user?.profile
      ?.nickname;

결과 타입은 상황에 따라:

string | undefined

가 됩니다.

`?.`는 값이 없으면 접근을 멈추기 때문에 런타임 오류를 방지하는 데 유용합니다.

다만 이것은 조건문을 이용한 좁히기와는 목적이 조금 다릅니다.

42. `??`로 기본값 적용하기

const displayName =
  user?.profile
      ?.nickname
  ?? "익명 사용자";

`nickname`이 `null` 또는 `undefined`라면 기본값을 사용합니다.

결과 타입은:

string

으로 더 단순해질 수 있습니다.

`??`는 다음 값들은 정상 값으로 유지합니다.

""
0
false

`||`와 중요한 차이입니다.

43. `||`와 `??`를 좁히기 관점에서 비교하기

다음 값이 있습니다.

const score:
  number | null = 0;

`||`를 사용합니다.

const first =
  score || 100;

`score`가 0이면 falsy이므로 결과는 100입니다.

반면:

const second =
  score ?? 100;

0은 `null`이나 `undefined`가 아니므로 결과는 0입니다.

||
→ falsy 전체를 대체

??
→ null과 undefined만 대체

값의 의미를 정확히 반영하는 연산자를 선택해야 합니다.

44. 타입 단언보다 좁히기가 안전하다

다음 함수를 보겠습니다.

function printValue(
  value:
    string | number
): void {
  const text =
    value as string;

  console.log(
    text.toUpperCase()
  );
}

컴파일은 통과할 수 있습니다.

하지만 실제로 숫자가 들어오면?

printValue(100);

런타임 오류가 발생할 수 있습니다.

타입 단언은 실제 타입을 검사하지 않기 때문입니다.

안전한 방법은 좁히기입니다.

function printValue(
  value:
    string | number
): void {
  if (
    typeof value ===
    "string"
  ) {
    console.log(
      value.toUpperCase()
    );
  } else {
    console.log(
      value.toFixed(2)
    );
  }
}

TypeScript에게:

“내가 알아.”

라고 우기는 대신,

“여기 증거가 있어.”

라고 보여주는 것입니다. 🔎

45. 외부 데이터에서 타입 좁히기

외부에서 받은 데이터는 `unknown`으로 시작하는 것이 안전한 경우가 있습니다.

function printExternalValue(
  value: unknown
): void {
  console.log(
    value.toUpperCase()
  );
}

오류가 발생합니다.

`unknown`은 바로 사용할 수 없습니다.

타입을 확인합니다.

function printExternalValue(
  value: unknown
): void {
  if (
    typeof value ===
    "string"
  ) {
    console.log(
      value.toUpperCase()
    );
  }
}

이제 안전합니다.

unknown
↓ typeof 검사
string

`unknown`과 타입 좁히기는 아주 좋은 짝입니다.

46. `unknown`을 숫자로 좁히기

function doubleValue(
  value: unknown
): number | null {
  if (
    typeof value ===
    "number"
  ) {
    return value * 2;
  }

  return null;
}

숫자일 때만 계산합니다.

doubleValue(10);

결과:

20

문자열이라면:

doubleValue("10");

결과:

null

타입 검사와 실제 프로그램 규칙이 한 코드 안에서 맞물립니다.

47. 여러 원시 타입을 처리하는 실무 함수

function formatValue(
  value:
    | string
    | number
    | boolean
    | null
): string {
  if (
    value === null
  ) {
    return "값 없음";
  }

  if (
    typeof value ===
    "string"
  ) {
    return value.trim();
  }

  if (
    typeof value ===
    "number"
  ) {
    return value
      .toLocaleString();
  }

  return value
    ? "예"
    : "아니오";
}

TypeScript가 마지막까지 추론합니다.

null 제거
↓
string | number | boolean

string 제거
↓
number | boolean

number 제거
↓
boolean

마지막 `return`에서 `value`는 불리언입니다.

48. 실습 1: 사용자 ID 포맷팅

사용자 ID가 숫자 또는 문자열일 수 있다고 가정하겠습니다.

type UserId =
  string | number;

표시용 문자열을 만드는 함수입니다.

function formatUserId(
  userId: UserId
): string {
  if (
    typeof userId ===
    "number"
  ) {
    return `USER-${userId
      .toString()
      .padStart(
        6,
        "0"
      )}`;
  }

  return userId
    .toUpperCase();
}

사용합니다.

console.log(
  formatUserId(25)
);

출력:

USER-000025
console.log(
  formatUserId(
    "user-admin"
  )
);

출력:

USER-ADMIN

49. 실습 2: API 메시지 처리

type ApiMessage =
  string
  | string[]
  | null;

메시지를 출력하는 함수를 만들어 보겠습니다.

function formatApiMessage(
  message: ApiMessage
): string {
  if (
    message === null
  ) {
    return "메시지 없음";
  }

  if (
    typeof message ===
    "string"
  ) {
    return message;
  }

  return message.join(", ");
}

마지막에서 `message`는 무엇일까요?

처음:

string
| string[]
| null

`null` 제거:

string
| string[]

`string` 제거:

string[]

따라서:

message.join(", ");

을 안전하게 사용할 수 있습니다.

50. 실습 3: 할인값 처리하기

할인이 퍼센트 숫자 또는 쿠폰 코드로 들어올 수 있다고 가정하겠습니다.

type Discount =
  number | string;
function applyDiscount(
  price: number,
  discount: Discount
): number {
  if (
    typeof discount ===
    "number"
  ) {
    return (
      price *
      (1 - discount)
    );
  }

  if (
    discount ===
    "WELCOME10"
  ) {
    return price * 0.9;
  }

  if (
    discount ===
    "VIP20"
  ) {
    return price * 0.8;
  }

  return price;
}

먼저 `typeof`로 문자열과 숫자를 나눴습니다.

그다음 문자열 영역에서 리터럴 값 비교로 다시 좁혔습니다.

타입 좁히기는 한 번만 쓰는 기술이 아닙니다.

단계를 거쳐 계속 정밀해질 수 있습니다.

51. 실습 4: 검색어 처리하기

검색어가 없을 수도 있고 문자열일 수도 있습니다.

function normalizeQuery(
  query:
    string
    | null
    | undefined
): string {
  if (
    query == null
  ) {
    return "";
  }

  return query
    .trim()
    .toLowerCase();
}

`query == null`에서 `null`과 `undefined`를 모두 제거했습니다.

그 이후:

query

는 `string`입니다.

52. 실습 5: 점수 출력 함수

0점도 유효한 값이라는 점을 반영해 보겠습니다.

function printScore(
  score:
    number | null
): string {
  if (
    score === null
  ) {
    return "미응시";
  }

  if (
    score === 0
  ) {
    return "0점";
  }

  return `${score}점`;
}

다음처럼 작성했다면:

if (!score) {
  return "미응시";
}

0점과 `null`이 함께 처리됐을 것입니다.

따라서 업무 의미에 맞는 조건이 중요합니다.

53. 실습 6: 설정값 포맷팅

type SettingValue =
  string
  | number
  | boolean
  | null;
function formatSetting(
  value: SettingValue
): string {
  if (
    value === null
  ) {
    return "설정 안 됨";
  }

  if (
    typeof value ===
    "string"
  ) {
    return value;
  }

  if (
    typeof value ===
    "number"
  ) {
    return value.toString();
  }

  return value
    ? "사용"
    : "사용 안 함";
}

이 함수 하나에 이번 편의 핵심이 거의 다 들어 있습니다.

54. 자주 발생하는 실수

실수 1. 유니언 타입에서 특정 타입의 메서드를 바로 사용하기

function printValue(
  value:
    string | number
): void {
  value.toUpperCase();
}

먼저 좁힙니다.

if (
  typeof value ===
  "string"
) {
  value.toUpperCase();
}

실수 2. `typeof null === "null"`이라고 생각하기

typeof null

결과는:

object

입니다.

`null`은 직접 비교합니다.

value === null

실수 3. `typeof value === "object"`만으로 객체라고 확신하기

`null`도 포함될 수 있습니다.

if (
  value !== null &&
  typeof value ===
  "object"
) {
  // ...
}

실수 4. truthiness로 0을 잃어버리기

if (score) {
  // ...
}

`score === 0`도 false입니다.

0이 정상 값이라면:

if (
  score !== null
) {
  // ...
}

처럼 명시적으로 확인합니다.

실수 5. truthiness로 빈 문자열을 잃어버리기

if (message) {
  // ...
}

`""`도 제외됩니다.

빈 문자열이 유효한 데이터라면 정확한 조건을 사용해야 합니다.

실수 6. 타입 단언으로 좁히기를 건너뛰기

const text =
  value as string;

실제 타입을 확인하지 않습니다.

가능하면:

if (
  typeof value ===
  "string"
) {
  // ...
}

처럼 런타임 증거를 사용합니다.

실수 7. 좁혀진 타입이 조건문 밖에서도 유지된다고 생각하기

if (
  typeof value ===
  "string"
) {
  value.toUpperCase();
}

value.toUpperCase();

두 번째 줄에서는 다시 `string | number`일 수 있습니다.

조건문 밖으로 나오면서 실행 경로가 합쳐졌기 때문입니다.

실수 8. 조기 반환 이후 타입 변화를 놓치기

if (
  value === null
) {
  return;
}

이후 코드에서는 `null`이 제거됩니다.

value.toString();

TypeScript가 코드 흐름을 분석하고 있다는 사실을 활용하면 중복 검사를 줄일 수 있습니다.

55. 타입 좁히기 선택 공식

타입을 좁혀야 할 때 다음 질문을 확인해 보세요.

문자열, 숫자, 불리언 같은 원시 타입인가?

`typeof`를 사용합니다.

typeof value === "string"

`null`인지 확인해야 하는가?

직접 비교합니다.

value !== null

`undefined`인지 확인해야 하는가?

value !== undefined

또는 적절한 타입 검사:

typeof value === "string"

`null`과 `undefined`를 동시에 제거하고 싶은가?

명시적으로 검사하거나 의도가 분명한 경우:

value != null

을 사용할 수 있습니다.

특정 리터럴 값인가?

status === "success"

여러 리터럴 상태를 분기하는가?

switch (status)

를 고려합니다.

잘못된 경우를 먼저 종료할 수 있는가?

조기 반환을 사용합니다.

if (
  value === null
) {
  return;
}

`0`, `""`, `false`가 정상 값인가?

단순 truthiness 검사보다 정확한 비교를 사용합니다.

56. 미니 퀴즈

문제 1

다음 조건문 안에서 `value`의 타입은 무엇일까요?

function test(
  value:
    string | number
) {
  if (
    typeof value ===
    "string"
  ) {
    // value의 타입?
  }
}

정답

string

문제 2

다음 `else`에서 `value`의 타입은 무엇일까요?

if (
  typeof value ===
  "string"
) {
  // ...
} else {
  // value?
}

원래 타입:

string | number

정답

number

문제 3

다음 코드의 문제는 무엇일까요?

if (
  typeof value ===
  "object"
) {
  // value는 무조건 객체?
}

정답

`null`도 `typeof null === "object"`이므로 `null` 가능성을 별도로 제거해야 할 수 있습니다.

문제 4

다음 조건문은 어떤 값을 함께 제거할까요?

if (score) {
  // ...
}

정답

`null`이나 `undefined`뿐 아니라 `0`, `NaN` 같은 falsy 숫자도 제외됩니다.

문제 5

다음 코드에서 `name`의 타입은 무엇일까요?

function test(
  name:
    string | null
) {
  if (
    name === null
  ) {
    return;
  }

  // name?
}

정답

string

`null`인 경우 함수가 이미 종료되었기 때문입니다.

문제 6

다음 코드의 마지막에서 `value` 타입은 무엇일까요?

function test(
  value:
    string
    | number
    | boolean
) {
  if (
    typeof value ===
    "string"
  ) {
    return;
  }

  if (
    typeof value ===
    "number"
  ) {
    return;
  }

  // value?
}

정답

boolean

문제 7

0점이 유효한 점수라면 다음 코드가 적절할까요?

if (score) {
  console.log(score);
}

정답

적절하지 않을 수 있습니다.

0은 falsy이므로 조건을 통과하지 못합니다.

if (
  score !== null
) {
  console.log(score);
}

처럼 정확한 조건을 사용하는 것이 좋습니다.

문제 8

다음 코드에서 `status`의 타입은 무엇일까요?

type Status =
  | "idle"
  | "loading"
  | "success";

function test(
  status: Status
) {
  if (
    status !== "idle"
  ) {
    // status?
  }
}

정답

"loading" | "success"

문제 9

외부 데이터를 안전하게 처리할 때 다음 중 어느 쪽이 더 안전할까요?

value: any

또는:

value: unknown

정답

일반적으로 `unknown`이 더 안전합니다.

사용하기 전에 타입 좁히기가 필요하기 때문입니다.

문제 10

다음 코드의 마지막 줄에서 왜 `toUpperCase()`가 가능할까요?

function test(
  value:
    string | number
) {
  if (
    typeof value ===
    "number"
  ) {
    return;
  }

  value.toUpperCase();
}

정답

숫자인 경우 함수가 이미 종료되었으므로 마지막까지 도달할 수 있는 타입은 `string`뿐입니다.

57. 핵심 정리

타입 좁히기

string | number

처럼 여러 가능성을 가진 타입에서 현재 코드에서 가능한 타입만 남기는 과정입니다.

`typeof`

if (
  typeof value ===
  "string"
) {
  // value: string
}

원시 타입을 좁힐 때 대표적으로 사용합니다.

반대쪽 타입도 추론

if (
  typeof value ===
  "string"
) {
  // string
} else {
  // number
}

`null` 주의

typeof null

결과:

object

따라서:

value !== null

처럼 직접 확인하는 것이 중요합니다.

Truthiness

if (value) {
  // ...
}

값의 참·거짓 여부를 이용해 좁힐 수 있습니다.

하지만:

0
""
false

도 falsy라는 점을 주의해야 합니다.

리터럴 비교

if (
  status === "success"
) {
  // status: "success"
}

조기 반환

if (
  value === null
) {
  return;
}

// 여기서는 null이 제거됨

제어 흐름 분석

TypeScript는 조건문, 반환, 재할당 등의 실행 흐름을 분석해 코드 위치별 타입을 계산합니다.

58. 이번 편의 핵심 흐름

오늘 배운 내용을 그림으로 정리해 보겠습니다.

넓은 타입

string
| number
| null

      ↓

value === null

      ↓

string
| number

      ↓

typeof value === "number"

      ↓

number

반대 경로:

string
| number

      ↓

number가 아님

      ↓

string

TypeScript는 코드를 읽으며 가능한 타입을 계속 제거합니다.

59. 타입 좁히기의 진짜 장점

타입 좁히기는 단순히 컴파일 오류를 없애는 기술이 아닙니다.

첫 번째 장점은 실제 런타임 안전성입니다.

if (
  typeof value ===
  "string"
) {
  value.toUpperCase();
}

실제로 문자열인지 검사한 뒤 사용합니다.

두 번째는 자동완성 품질입니다.

문자열로 좁혀지면 에디터는 문자열 메서드를 알려줍니다.

toUpperCase
toLowerCase
trim
includes
startsWith
...

세 번째는 타입 단언 감소입니다.

value as string

보다:

typeof value === "string"

이 실제 데이터와 타입 정보를 연결합니다.

네 번째는 코드 자체가 문서가 된다는 것입니다.

if (
  value === null
) {
  return;
}

이 한 줄만으로:

이 아래에서는 값이 반드시 존재한다.

라는 업무 규칙이 드러납니다.

60. 마무리

TypeScript에서 유니언 타입은 강력합니다.

string | number

하지만 가능성이 여러 개이기 때문에 바로 특정 타입의 기능을 사용할 수 없습니다.

value.toUpperCase();

먼저 증거가 필요합니다.

if (
  typeof value ===
  "string"
) {
  value.toUpperCase();
}

`typeof`는 TypeScript가 타입을 좁히는 가장 기본적인 도구입니다.

typeof value === "string"
typeof value === "number"
typeof value === "boolean"

리터럴 값을 직접 비교할 수도 있습니다.

status === "success"

`null`은 특별히 조심해야 합니다.

typeof null === "object"

따라서 필요하다면:

value !== null

로 직접 검사합니다.

truthiness도 사용할 수 있습니다.

if (value) {
  // ...
}

하지만:

0
""
false

까지 제거된다는 점을 반드시 생각해야 합니다.

그리고 TypeScript는 단순한 조건문 한 줄만 보는 것이 아닙니다.

if (
  value === null
) {
  return;
}

이후 코드에서는 `null`이 제거되었다는 사실까지 이해합니다.

이것이 제어 흐름 분석입니다.

이번 편의 핵심을 한 문장으로 정리하면 다음과 같습니다.

TypeScript는 조건문과 실행 흐름을 증거로 사용해 유니언 타입의 가능성을 하나씩 제거한다.

타입 좁히기는 TypeScript와 벌이는 스무고개와 비슷합니다.

문자열입니까?
→ 아니오.

null입니까?
→ 아니오.

숫자입니까?
→ 예.

TypeScript:
“좋아, 이제 number라고 확신할 수 있겠군.”

TypeScript가 까다로운 것이 아닙니다.

증거 없이 용의자를 체포하지 않는 꽤 원칙적인 탐정일 뿐입니다. 🕵️‍♂️

61. 다음 편 예고

이번 편에서는 주로 원시 타입을 구분했습니다.

typeof value === "string"
typeof value === "number"

하지만 객체는 어떻게 구분할까요?

다음 두 타입이 있다고 해보겠습니다.

interface User {
  name: string;
  email: string;
}
interface Admin {
  name: string;
  email: string;
  permissions: string[];
}

값의 타입은:

User | Admin

입니다.

`typeof`를 사용해 보겠습니다.

typeof value === "object"

문제가 있습니다.

둘 다 객체입니다.

User
→ object

Admin
→ object

구분이 안 됩니다.

그런데 `Admin`에만 존재하는 속성이 하나 있습니다.

permissions

이럴 때 JavaScript의 `in` 연산자를 사용할 수 있습니다.

if (
  "permissions" in value
) {
  // Admin
}

클래스 인스턴스라면 `instanceof`도 사용할 수 있습니다.

if (
  error instanceof Error
) {
  console.log(
    error.message
  );
}

더 나아가 직접 타입 판별 함수를 만들 수도 있습니다.

function isAdmin(
  user: User | Admin
): user is Admin {
  return (
    "permissions" in user
  );
}

그리고 객체 자체에 구분용 속성을 두는 방법도 있습니다.

type Shape =
  | {
      kind: "circle";
      radius: number;
    }
  | {
      kind: "square";
      size: number;
    };
if (
  shape.kind === "circle"
) {
  // Circle 타입
}

이 방식은 TypeScript 실무에서 매우 중요한 판별 유니언(Discriminated Union)으로 이어집니다.

다음 이야기

[TypeScript 완전정복 #19] 객체 타입은 어떻게 구분할까? | in, instanceof, 타입 가드와 판별 유니언 완벽 이해하기

  • `typeof`만으로 객체를 구분하기 어려운 이유는 무엇일까요?
  • `in` 연산자로 객체 타입을 어떻게 좁힐까요?
  • `instanceof`는 언제 사용할까요?
  • 클래스 인스턴스는 어떻게 구분할까요?
  • 사용자 정의 타입 가드는 무엇일까요?
  • `value is Type` 문법은 어떤 의미일까요?
  • 타입 가드 함수는 언제 만들어야 할까요?
  • 판별 유니언은 무엇일까요?
  • `kind`, `type`, `status` 같은 속성이 왜 중요할까요?
  • `switch`와 판별 유니언을 함께 사용하면 무엇이 좋아질까요?
  • 모든 경우를 빠짐없이 처리했는지 TypeScript에게 검사시킬 수 있을까요?

다음 편에서는 원시 타입 탐문 수사를 마치고, 이제 객체들의 신분증을 검사해 보겠습니다.겉모습은 전부 `"object"`라고 주장하지만 TypeScript 탐정에게는 `in`, `instanceof`, 타입 가드라는 새로운 수사 장비가 기다리고 있습니다. 🔍

댓글

0

댓글을 불러오는 중입니다.