목록으로

프로그래밍 · TypeScript

TypeScript 완전정복 19화: 객체 타입은 어떻게 구분할까?

BeanCon
TypeScript에서 in, instanceof, 타입 가드와 판별 유니언으로 객체 타입을 구분하는 방법을 설명하는 대표 이미지

TypeScript 완전정복 19화입니다. 객체 타입을 구분하기 위한 in 연산자, instanceof, 사용자 정의 타입 가드, 판별 유니언을 정리했습니다. 선택적 속성, unknown 객체 검사, value is Type 문법, 배열 필터링, API 상태 모델링, never를 이용한 완전성 검사, 실무 예제와 미니 퀴즈까지 다룹니다.

목차

in, instanceof, 타입 가드와 판별 유니언 완벽 이해하기

지난 편에서 `typeof`를 이용한 타입 좁히기를 배웠습니다.

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

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

이 정도만 알아도 `string | number`처럼 원시 타입을 구분하는 코드는 꽤 편해집니다.

문제는 객체입니다.

interface User {
  name: string;
  email: string;
}

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

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

function printAccount(
  account: User | Admin
): void {
  // account는 User일까?
  // Admin일까?
}

지난 시간에 배운 `typeof`부터 떠올릴 수 있습니다.

console.log(
  typeof account
);

하지만 `User`도 객체이고 `Admin`도 객체입니다.

둘 다 결과는 똑같습니다.

object

`typeof` 탐정이 갑자기 수사 보고서를 이렇게 제출한 셈입니다.

용의자 둘 다 객체입니다.

“그건 나도 알아.”

이제 객체 안으로 조금 더 들어가 봐야 합니다.

다행히 JavaScript와 TypeScript에는 객체의 정체를 구분하기 위한 여러 방법이 있습니다.

"permissions" in account

클래스 인스턴스라면:

value instanceof Error

직접 판별 함수를 만들 수도 있습니다.

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

그리고 객체 자체에 신분증 역할을 하는 속성을 넣는 방법도 있습니다.

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

이것이 TypeScript 실무에서 정말 자주 등장하는 판별 유니언(Discriminated Union)입니다.

TypeScript 공식 문서에서도 `in`, `instanceof`, 타입 프레디킷(type predicate), 판별 유니언, `never`를 이용한 완전성 검사를 대표적인 narrowing 기법으로 설명하고 있습니다.

이번 편에서는 객체들이 전부 `"object"`라는 같은 가면을 쓰고 있을 때, 그 안에서 실제 타입을 구분하는 방법을 하나씩 살펴보겠습니다.

1. 이번 시간에 배울 내용

이번 편에서는 다음 내용을 다룹니다.

  • 객체는 왜 `typeof`만으로 구분하기 어려운가
  • `in` 연산자로 객체 속성 확인하기
  • `in`을 이용한 타입 좁히기
  • 선택적 속성에서 `in`을 사용할 때 주의할 점
  • `instanceof`란 무엇인가
  • `Date`, `Error` 같은 클래스 인스턴스 구분하기
  • 직접 만든 클래스에서 `instanceof` 사용하기
  • 사용자 정의 타입 가드란 무엇인가
  • `value is Type` 문법 이해하기
  • `unknown` 데이터를 타입 가드로 검사하기
  • 잘못 만든 타입 가드가 위험한 이유
  • 판별 유니언이란 무엇인가
  • `kind`, `type`, `status` 속성을 사용하는 이유
  • `switch`와 판별 유니언
  • `never`를 이용한 빠짐없는 분기 검사
  • API 응답 상태를 판별 유니언으로 설계하기
  • React 스타일 UI 상태 모델링
  • 어떤 타입 좁히기 방법을 선택해야 하는가

이번 시간의 핵심은 다음 한 문장으로 정리할 수 있습니다.

객체 타입을 안전하게 구분하려면 실제 런타임에서 확인할 수 있는 증거가 필요하다.

2. `typeof`만으로 객체를 구분해 보자

먼저 가장 단순하게 시도해 보겠습니다.

interface User {
  name: string;
  email: string;
}

interface Admin {
  name: string;
  email: string;
  permissions: string[];
}
function printAccount(
  account: User | Admin
): void {
  if (
    typeof account === "object"
  ) {
    console.log(
      account.name
    );
  }
}

여기까지는 괜찮습니다.

문제는 관리자 전용 속성입니다.

account.permissions;

사용할 수 없습니다.

왜냐하면 TypeScript 입장에서는 여전히 다음 상태이기 때문입니다.

User | Admin

`typeof account === "object"`는 둘을 전혀 구분하지 못했습니다.

User
→ object

Admin
→ object

교복을 입은 학생 두 명을 보고 “둘 다 사람입니다”라고 판별한 것과 비슷합니다.

틀린 말은 아닌데 우리가 원하는 정보도 아닙니다.

3. 객체에서 다른 점을 찾아보자

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

interface User {
  name: string;
  email: string;
}

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

공통 속성은 다음입니다.

name
email

차이가 하나 있습니다.

Admin
→ permissions 있음

User
→ permissions 없음

그렇다면 객체에 `permissions` 속성이 존재하는지 확인하면 되지 않을까요?

JavaScript에는 바로 그런 연산자가 있습니다.

in

입니다.

4. `in` 연산자란?

JavaScript의 `in` 연산자는 특정 속성이 객체에 존재하는지 확인합니다.

const user = {
  name: "김타입",
  email: "type@example.com",
};
console.log(
  "name" in user
);

결과:

true

존재하지 않는 속성을 확인하면:

console.log(
  "permissions" in user
);

결과:

false

기본 문법은 다음과 같습니다.

"속성이름" in 객체

즉 다음 코드는:

"permissions" in account

`account` 객체에 `permissions`라는 속성이 존재하는지 실행 중에 확인합니다.

5. `in`으로 객체 타입 좁히기

이제 사용자와 관리자를 구분해 보겠습니다.

function printAccount(
  account: User | Admin
): void {
  if (
    "permissions" in account
  ) {
    console.log(
      account.permissions
    );
  }
}

조건문 안에서 TypeScript는 `account`를 `Admin`으로 좁힐 수 있습니다.

조건문 밖

User | Admin

↓

"permissions" in account

↓

조건문 안

Admin

따라서 관리자 전용 속성을 안전하게 사용할 수 있습니다.

account.permissions;

TypeScript의 `in` narrowing은 유니언 타입에서 특정 속성을 가진 구성원을 기준으로 타입을 좁힐 수 있습니다. 공식 문서에서도 `"swim" in pet` 형태를 대표 예제로 설명합니다.

6. `else`에서는 어떻게 될까?

조금 더 완성된 코드를 작성해 보겠습니다.

function printAccount(
  account: User | Admin
): void {
  if (
    "permissions" in account
  ) {
    console.log(
      "관리자입니다."
    );

    console.log(
      account.permissions
    );

    return;
  }

  console.log(
    "일반 사용자입니다."
  );

  console.log(
    account.email
  );
}

`permissions`가 존재하는 쪽은 `Admin`.

그렇지 않은 쪽은 `User`로 좁혀집니다.

User | Admin

        ↓

permissions 존재?

     /       \
   YES       NO

 Admin       User

객체 타입을 구분하는 가장 기본적인 패턴입니다.

7. `in`은 타입 이름을 검사하는 것이 아니다

중요한 부분입니다.

다음과 같은 문법은 없습니다.

"Admin" in account

`in`은 TypeScript의 타입 이름을 검사하는 기능이 아닙니다.

실제 JavaScript 객체의 속성 존재 여부를 확인합니다.

따라서:

interface Admin {
  permissions: string[];
}

이 타입을 구분하려면:

"permissions" in account

처럼 런타임 객체에 실제로 존재할 수 있는 속성을 사용해야 합니다.

TypeScript의 타입 정보는 컴파일 후 대부분 사라지기 때문입니다.

8. 좋은 판별 속성을 선택하자

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

interface Dog {
  name: string;
  bark(): void;
}

interface Cat {
  name: string;
  meow(): void;
}

`name`은 좋은 판별 기준이 아닙니다.

둘 다 가지고 있기 때문입니다.

"name" in animal

이 조건으로는 구분이 되지 않습니다.

대신:

"bark" in animal

을 사용할 수 있습니다.

function makeSound(
  animal: Dog | Cat
): void {
  if ("bark" in animal) {
    animal.bark();
    return;
  }

  animal.meow();
}

실제 타입 사이에 차이가 나는 속성을 선택해야 합니다.

9. 선택적 속성이 있다면 이야기가 조금 복잡해진다

다음 타입을 보겠습니다.

interface Fish {
  swim(): void;
}

interface Bird {
  fly(): void;
}

interface Human {
  swim?: () => void;
  fly?: () => void;
}

`Human`은 `swim`을 가질 수도 있고 없을 수도 있습니다.

다음 조건을 사용합니다.

if ("swim" in animal) {
  // ...
}

`Fish`는 당연히 들어옵니다.

하지만 `swim`이라는 선택적 속성을 가질 수 있는 `Human`도 이 가능성에 포함될 수 있습니다.

반대쪽에서도 `Human`이 완전히 사라진다고 단정할 수 없습니다.

TypeScript 공식 문서도 `in` narrowing에서 선택적 속성을 가진 타입은 true와 false 양쪽에 남을 수 있음을 설명합니다.

즉 `in`을 사용할 때는 단순히 속성 이름만 볼 것이 아니라 해당 속성이 필수인지 선택적인지도 함께 생각해야 합니다.

10. 객체 타입을 구분하기 좋게 설계하는 것도 중요하다

다음 타입은 구분하기가 조금 애매합니다.

interface User {
  name: string;
  email: string;
}

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

물론 `permissions`를 사용하면 됩니다.

하지만 객체 종류가 점점 많아진다면 어떨까요?

User
Admin
Manager
Guest
SystemUser

매번:

"permissions" in user
"expiresAt" in user
"department" in user

를 찾아다니기 시작하면 타입 구분 코드가 복잡해질 수 있습니다.

이 문제는 뒤에서 판별 유니언으로 훨씬 깔끔하게 해결할 수 있습니다.

그 전에 또 하나의 중요한 도구를 만나보겠습니다.

11. `instanceof`란?

`instanceof` 역시 JavaScript 연산자입니다.

어떤 객체가 특정 생성자나 클래스 계열로 만들어졌는지 런타임에서 확인할 때 사용합니다.

대표적인 예가 `Date`입니다.

const today =
  new Date();
console.log(
  today instanceof Date
);

결과:

true

문법은 다음과 같습니다.

객체 instanceof 클래스

TypeScript는 이 런타임 검사를 타입 narrowing에 활용합니다.

12. `Date`와 문자열 구분하기

다음 함수는 날짜를 문자열 또는 `Date` 객체로 받을 수 있습니다.

function formatDate(
  value: string | Date
): string {
  if (
    value instanceof Date
  ) {
    return value
      .toISOString();
  }

  return value;
}

`instanceof Date` 내부에서는:

value

의 타입이:

Date

로 좁혀집니다.

그 아래에서는 문자열만 남습니다.

string

13. `Error` 처리에서 `instanceof`는 정말 자주 만난다

`catch`에서 오류를 다룰 때 대표적으로 사용됩니다.

try {
  throw new Error(
    "서버 연결 실패"
  );
} catch (error: unknown) {
  if (
    error instanceof Error
  ) {
    console.log(
      error.message
    );
  }
}

`error`가 `unknown`이면 바로:

error.message

를 사용할 수 없습니다.

하지만:

error instanceof Error

를 통과하면 TypeScript가 `Error`로 좁혀줍니다.

console.log(
  error.message
);

이제 안전합니다.

14. 문자열 오류도 던질 수 있다는 점을 기억하자

JavaScript에서는 반드시 `Error` 객체만 던져야 하는 것은 아닙니다.

예를 들어 이런 코드도 실행됩니다.

throw "서버 오류";

그래서 다음처럼 작성하는 편이 안전할 수 있습니다.

function getErrorMessage(
  error: unknown
): string {
  if (
    error instanceof Error
  ) {
    return error.message;
  }

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

  return "알 수 없는 오류";
}

여기에는 지금까지 배운 narrowing이 함께 사용됩니다.

unknown

↓ instanceof

Error

또는

↓ typeof

string

15. 직접 만든 클래스도 `instanceof`로 구분할 수 있다

이번에는 클래스를 하나 만들어 보겠습니다.

class Dog {
  constructor(
    public name: string
  ) {}

  bark(): void {
    console.log(
      "멍멍!"
    );
  }
}

고양이 클래스도 만듭니다.

class Cat {
  constructor(
    public name: string
  ) {}

  meow(): void {
    console.log(
      "야옹!"
    );
  }
}

함수에서 구분합니다.

function makeSound(
  animal: Dog | Cat
): void {
  if (
    animal instanceof Dog
  ) {
    animal.bark();
    return;
  }

  animal.meow();
}

`instanceof Dog`를 통과한 영역에서는 `Dog`로 좁혀집니다.

16. `instanceof`는 interface를 검사할 수 없다

여기서 초보자가 자주 시도하는 코드가 있습니다.

interface User {
  name: string;
}
if (
  value instanceof User
) {
}

이 코드는 사용할 수 없습니다.

왜일까요?

`interface`는 TypeScript 타입 영역에만 존재하고 JavaScript 실행 시점에는 존재하지 않기 때문입니다.

`instanceof` 오른쪽에는 런타임에 실제로 존재하는 생성자 함수나 클래스가 필요합니다.

class
→ JavaScript에도 존재
→ instanceof 가능

interface
→ 타입 검사 후 사라짐
→ instanceof 불가능

17. `in`과 `instanceof`는 언제 다르게 사용할까?

둘 다 객체 타입을 좁힐 수 있지만 쓰임새가 조금 다릅니다.

`in`

객체 구조의 특정 속성이 존재하는지 확인합니다.

"permissions" in account

일반 객체, API 응답 객체처럼 클래스가 아닌 데이터에 특히 유용합니다.

`instanceof`

특정 클래스나 생성자를 기준으로 구분합니다.

error instanceof Error
value instanceof Date

클래스 인스턴스를 다룰 때 자연스럽습니다.

간단히 기억하면:

객체 속성을 본다
→ in

어떤 클래스로 만들어졌는지 본다
→ instanceof

18. 그런데 검사 코드가 계속 반복된다면?

다음 코드를 여러 곳에서 사용한다고 생각해 보겠습니다.

if (
  "permissions" in account
) {
  // Admin 처리
}

파일마다 계속 반복됩니다.

if (
  "permissions" in account
) {
}
if (
  "permissions" in account
) {
}

이럴 때 판별 로직을 함수로 분리하고 싶어집니다.

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

그리고 이렇게 사용해 봅니다.

if (isAdmin(account)) {
  account.permissions;
}

여기서 바로 등장하는 것이 사용자 정의 타입 가드(User-defined Type Guard)입니다.

19. 사용자 정의 타입 가드란?

함수가 단순히 `true`, `false`만 반환하는 것이 아니라:

이 함수가 true라면 이 값은 특정 타입이다.

라는 정보를 TypeScript에게 알려주는 함수입니다.

문법은 다음과 같습니다.

매개변수 is 타입

예:

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

여기서 핵심은:

account is Admin

입니다.

이것을 타입 프레디킷(Type Predicate)이라고 합니다. TypeScript 공식 문서에서도 사용자 정의 타입 가드는 `parameterName is Type` 형태의 반환 타입을 이용해 조건문 안에서 해당 변수를 특정 타입으로 좁히는 방식으로 설명합니다.

20. `account is Admin`을 말로 읽어보자

다음 코드를 보면 처음에는 낯설 수 있습니다.

function isAdmin(
  account: User | Admin
): account is Admin

말로 읽어보면 쉽습니다.

`isAdmin()`이 true를 반환했다면 `account`는 `Admin`이다.

실제 함수 구현:

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

사용합니다.

if (
  isAdmin(account)
) {
  console.log(
    account.permissions
  );
}

조건문 내부의 타입:

Admin

이 됩니다.

21. 타입 가드 함수의 장점

가장 큰 장점은 복잡한 검사 코드를 재사용할 수 있다는 것입니다.

function isAdmin(
  account: User | Admin
): account is Admin {
  return (
    "permissions" in account &&
    Array.isArray(
      account.permissions
    )
  );
}

이제 여러 곳에서:

if (isAdmin(account)) {
  // Admin
}

만 작성하면 됩니다.

타입 판단 규칙을 한곳에 모을 수 있습니다.

특히 API 데이터나 `unknown` 데이터를 검사할 때 유용합니다.

22. `unknown` 객체를 검사해 보자

외부 API에서 다음 데이터가 들어왔다고 가정하겠습니다.

const data: unknown =
  JSON.parse(jsonText);

우리는 이 데이터가 `User`이길 기대하고 있습니다.

interface User {
  id: number;
  name: string;
  email: string;
}

하지만 다음처럼 바로 단언하는 것은 위험합니다.

const user =
  data as User;

실제 데이터가:

{
  "banana": true
}

여도 타입 단언은 런타임 검증을 해주지 않습니다.

이럴 때 타입 가드를 만들 수 있습니다.

23. `unknown`을 검사하는 타입 가드 만들기

먼저 가장 기본적인 형태입니다.

function isUser(
  value: unknown
): value is User {
  if (
    typeof value !== "object" ||
    value === null
  ) {
    return false;
  }

  return (
    "id" in value &&
    "name" in value &&
    "email" in value
  );
}

사용합니다.

if (isUser(data)) {
  console.log(
    data.name
  );
}

`if` 안에서 `data`는 `User`로 좁혀집니다.

하지만 아직 개선할 부분이 있습니다.

24. 속성이 있다는 것만으로 충분할까?

다음 객체를 생각해 보겠습니다.

const data = {
  id: "나는 숫자가 아님",
  name: 100,
  email: false,
};

속성 이름은 모두 존재합니다.

id
name
email

하지만 타입은 전부 틀렸습니다.

따라서 실제 외부 데이터를 검증하려면 속성 존재 여부뿐 아니라 값의 타입도 검사하는 편이 좋습니다.

25. 조금 더 제대로 된 `isUser`

function isUser(
  value: unknown
): value is User {
  if (
    typeof value !== "object" ||
    value === null
  ) {
    return false;
  }

  if (
    !("id" in value) ||
    !("name" in value) ||
    !("email" in value)
  ) {
    return false;
  }

  return (
    typeof value.id === "number" &&
    typeof value.name === "string" &&
    typeof value.email === "string"
  );
}

이제 다음 조건을 모두 검사합니다.

객체인가?
↓
null은 아닌가?
↓
id가 있는가?
↓
name이 있는가?
↓
email이 있는가?
↓
각 값의 타입도 맞는가?

외부 데이터 검증에서는 이런 단계가 중요합니다.

26. 타입 가드는 TypeScript에게 하는 약속이다

여기서 정말 중요한 함정이 하나 있습니다.

다음 타입 가드를 작성해도 TypeScript는 작성자의 논리를 완벽하게 대신 검증해주지 않습니다.

function isUser(
  value: unknown
): value is User {
  return true;
}

이 함수는 무조건 `true`를 반환합니다.

그러면:

if (isUser(data)) {
  data.name;
}

TypeScript는 `data`를 `User`라고 믿게 됩니다.

하지만 실제 데이터는 전혀 다른 값일 수 있습니다.

즉:

value is User

는 마법 검증기가 아니라 개발자가 TypeScript에게 제공하는 판별 계약입니다.

계약 내용이 거짓이면 런타임 오류가 날 수 있습니다.

27. 타입 가드는 최대한 단순하고 검증 가능하게 만들자

좋은 타입 가드는 보통 다음 질문에 명확하게 답할 수 있어야 합니다.

어떤 값을 받는가?

무엇을 확인하는가?

true라면 정말 해당 타입이라고
보장할 수 있는가?

예를 들어:

function isString(
  value: unknown
): value is string {
  return (
    typeof value ===
    "string"
  );
}

아주 명확합니다.

객체 타입이라면 핵심 속성을 확인합니다.

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

복잡한 데이터 검증이라면 전용 검증 라이브러리를 사용하는 것도 실무에서 흔한 선택이지만, TypeScript 자체의 타입 가드 원리를 먼저 이해해두는 것이 중요합니다.

28. 배열 필터링에서도 타입 가드가 유용하다

다음 배열을 보겠습니다.

const values:
  (string | number)[] = [
    "TypeScript",
    100,
    "JavaScript",
    200,
  ];

문자열만 남기고 싶습니다.

function isString(
  value: string | number
): value is string {
  return (
    typeof value ===
    "string"
  );
}
const strings =
  values.filter(isString);

`strings`는 문자열 배열로 좁혀질 수 있습니다.

string[]

이런 패턴은 여러 타입이 섞인 데이터를 정리할 때 꽤 유용합니다.

29. 지금까지 방식의 공통점

지금까지 객체를 구분하기 위해 여러 방법을 사용했습니다.

"permissions" in account
error instanceof Error
isAdmin(account)

모두 잘 작동합니다.

하지만 객체 종류가 많아질수록 한 가지 문제가 생깁니다.

타입을 구분하기 위해 구조의 차이를 매번 찾아야 한다는 것입니다.

예를 들어 결제 타입이 세 개라면:

CreditCardPayment
BankTransferPayment
PointPayment

어떤 속성이 누구에게만 있는지 찾아야 합니다.

cardNumber가 있으면 카드?
bankCode가 있으면 계좌?
point가 있으면 포인트?

처음에는 괜찮지만 타입이 커질수록 점점 읽기 힘들어집니다.

이럴 때 아예 객체에 나는 누구인지 알려주는 속성을 넣으면 어떨까요?

30. 판별 유니언이란?

다음 도형 타입을 보겠습니다.

type Shape =
  | {
      kind: "circle";
      radius: number;
    }
  | {
      kind: "square";
      size: number;
    };

두 객체에 공통 속성이 있습니다.

kind

하지만 값이 다릅니다.

Circle
→ kind: "circle"

Square
→ kind: "square"

이 `kind`를 이용하면 타입을 아주 쉽게 구분할 수 있습니다.

function getArea(
  shape: Shape
): number {
  if (
    shape.kind === "circle"
  ) {
    return (
      Math.PI *
      shape.radius ** 2
    );
  }

  return (
    shape.size ** 2
  );
}

`kind === "circle"` 영역에서 `shape`는 정확히 원 타입으로 좁혀집니다.

이처럼 유니언의 각 구성원이 공통 속성을 가지고 있고, 그 속성의 값이 서로 다른 리터럴 타입으로 구분되는 구조를 판별 유니언이라고 합니다. TypeScript 공식 문서 역시 `kind` 같은 공통 리터럴 속성을 이용해 유니언 구성원을 좁히는 방식을 discriminated union으로 설명합니다.

31. 왜 판별 속성이 필요한가?

다음 구조와 비교해 보겠습니다.

interface Circle {
  radius: number;
}

interface Square {
  size: number;
}

type Shape =
  Circle | Square;

구분하려면:

if ("radius" in shape) {
}

를 사용할 수 있습니다.

작동은 합니다.

하지만 타입 자체를 읽었을 때 다음 의미가 바로 보이지 않습니다.

이 객체가 원인가?
사각형인가?

판별 속성을 추가하면:

interface Circle {
  kind: "circle";
  radius: number;
}

interface Square {
  kind: "square";
  size: number;
}

객체 자체가 자신의 정체를 명확하게 표현합니다.

{
  kind: "circle",
  radius: 10
}

코드를 처음 보는 사람도 바로 이해할 수 있습니다.

32. 판별 속성 이름은 무엇을 사용할까?

꼭 `kind`여야 하는 것은 아닙니다.

실무에서는 목적에 따라 여러 이름을 사용합니다.

kind
type
status
state
event

예를 들어 결제 수단이라면:

type Payment =
  | {
      type: "card";
      cardNumber: string;
    }
  | {
      type: "bank";
      bankCode: string;
    };

API 상태라면:

type ApiState =
  | {
      status: "loading";
    }
  | {
      status: "success";
      data: User;
    }
  | {
      status: "error";
      message: string;
    };

객체가 무엇을 구분하는지 잘 드러나는 이름을 사용하는 것이 좋습니다.

33. API 상태를 판별 유니언으로 만들어보자

처음에는 다음처럼 설계할 수 있습니다.

interface ApiState {
  loading: boolean;
  data?: User;
  error?: string;
}

그런데 이상한 조합이 가능해집니다.

const state: ApiState = {
  loading: true,
  data: {
    id: 1,
    name: "김타입",
  },
  error:
    "서버 오류",
};

현재 로딩 중인데 데이터도 있고 오류도 있습니다.

이 상태가 정말 가능한 상태일까요?

판별 유니언으로 바꿔보겠습니다.

34. 가능한 상태만 타입으로 만든다

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

이제 각 상태에 필요한 데이터가 정확하게 연결됩니다.

로딩:

const loadingState:
  ApiState = {
  status: "loading",
};

성공:

const successState:
  ApiState = {
  status: "success",
  data: {
    id: 1,
    name: "김타입",
  },
};

오류:

const errorState:
  ApiState = {
  status: "error",
  error:
    "서버 연결 실패",
};

잘못된 조합을 만들기 어려워졌습니다.

이것이 판별 유니언의 큰 장점입니다.

35. 불가능한 상태를 타입으로 막는다

다음 객체는 허용되지 않습니다.

const state:
  ApiState = {
  status: "success",
  error:
    "서버 오류",
};

`success` 상태에는 `data`가 필요합니다.

그리고 `error`는 해당 상태의 구조에 없습니다.

타입을 잘 설계하면 단순히 자동완성이 좋아지는 것을 넘어 업무상 존재하면 안 되는 상태 자체를 표현하기 어렵게 만들 수 있습니다.

이 부분이 실무에서 판별 유니언을 좋아하는 이유 중 하나입니다.

36. `switch`와 판별 유니언은 궁합이 좋다

function renderState(
  state: ApiState
): string {
  switch (state.status) {
    case "loading":
      return "불러오는 중...";

    case "success":
      return (
        `사용자: ${
          state.data.name
        }`
      );

    case "error":
      return (
        `오류: ${
          state.error
        }`
      );
  }
}

각 `case` 안에서 TypeScript가 타입을 정확하게 좁힙니다.

case "loading"
→ loading 상태

case "success"
→ success 상태
→ data 사용 가능

case "error"
→ error 상태
→ error 사용 가능

별도의 타입 단언이 필요하지 않습니다.

37. `if`문으로 작성해도 된다

물론 `switch`만 가능한 것은 아닙니다.

function renderState(
  state: ApiState
): string {
  if (
    state.status ===
    "loading"
  ) {
    return "불러오는 중...";
  }

  if (
    state.status ===
    "success"
  ) {
    return state.data.name;
  }

  return state.error;
}

상태가 2~3개 정도라면 `if`도 충분히 읽기 좋습니다.

상태가 많아지고 모든 경우를 빠짐없이 처리하고 싶다면 `switch`가 더 편한 경우가 많습니다.

38. 실무 예제: 로그인 상태

로그인 상태도 판별 유니언으로 표현하기 좋습니다.

interface User {
  id: number;
  name: string;
}
type AuthState =
  | {
      status:
        "checking";
    }
  | {
      status:
        "authenticated";

      user: User;
    }
  | {
      status:
        "guest";
    };

사용합니다.

function getWelcomeMessage(
  state: AuthState
): string {
  switch (state.status) {
    case "checking":
      return (
        "로그인 상태 확인 중"
      );

    case "authenticated":
      return (
        `${state.user.name}님 환영합니다.`
      );

    case "guest":
      return (
        "로그인이 필요합니다."
      );
  }
}

`guest`인데 `user`가 존재하는 이상한 상태를 표현할 필요가 없어집니다.

39. 실무 예제: 결제 수단

type PaymentMethod =
  | {
      type: "card";
      cardNumber: string;
      cardCompany: string;
    }
  | {
      type: "bank";
      bankName: string;
      accountNumber: string;
    }
  | {
      type: "point";
      point: number;
    };

결제 정보를 출력합니다.

function printPayment(
  payment:
    PaymentMethod
): void {
  switch (payment.type) {
    case "card":
      console.log(
        payment.cardCompany
      );
      break;

    case "bank":
      console.log(
        payment.bankName
      );
      break;

    case "point":
      console.log(
        `${payment.point}P`
      );
      break;
  }
}

`payment.type`만 보면 어떤 속성을 사용할 수 있는지 TypeScript가 알아냅니다.

40. 판별 속성은 리터럴 타입이어야 효과가 좋다

다음 타입을 보겠습니다.

interface Circle {
  kind: string;
  radius: number;
}

interface Square {
  kind: string;
  size: number;
}

두 타입 모두 `kind: string`입니다.

shape.kind === "circle"

이라고 검사해도 `Circle`이라는 사실을 충분히 표현하지 못합니다.

판별 속성에는 구체적인 리터럴 타입을 사용해야 합니다.

interface Circle {
  kind: "circle";
  radius: number;
}

interface Square {
  kind: "square";
  size: number;
}

이제 `kind` 값 자체가 타입의 신분증 역할을 합니다.

41. 타입이 늘어날 때 판별 유니언의 진가가 나온다

삼각형을 추가해 보겠습니다.

interface Triangle {
  kind: "triangle";
  width: number;
  height: number;
}
type Shape =
  | Circle
  | Square
  | Triangle;

면적 계산 함수를 수정합니다.

function getArea(
  shape: Shape
): number {
  switch (shape.kind) {
    case "circle":
      return (
        Math.PI *
        shape.radius ** 2
      );

    case "square":
      return (
        shape.size ** 2
      );

    case "triangle":
      return (
        shape.width *
        shape.height /
        2
      );
  }
}

각 타입별 속성이 자동으로 따라옵니다.

42. 그런데 새로운 타입을 추가하고 분기를 잊으면?

이번에는 직사각형을 추가한다고 해보겠습니다.

interface Rectangle {
  kind: "rectangle";
  width: number;
  height: number;
}
type Shape =
  | Circle
  | Square
  | Triangle
  | Rectangle;

그런데 `getArea()`는 수정하지 않았습니다.

큰 프로젝트에서는 이런 실수가 충분히 생길 수 있습니다.

타입에는 rectangle 추가

그런데

switch에는 rectangle 없음

TypeScript에게 이런 누락도 검사해달라고 할 수 있습니다.

여기서 다시 `never`가 등장합니다.

43. `never`를 이용한 완전성 검사

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

function assertNever(
  value: never
): never {
  throw new Error(
    `처리되지 않은 값: ${value}`
  );
}

그리고 `switch`의 마지막에 사용합니다.

function getArea(
  shape: Shape
): number {
  switch (shape.kind) {
    case "circle":
      return (
        Math.PI *
        shape.radius ** 2
      );

    case "square":
      return (
        shape.size ** 2
      );

    case "triangle":
      return (
        shape.width *
        shape.height /
        2
      );

    case "rectangle":
      return (
        shape.width *
        shape.height
      );

    default:
      return assertNever(
        shape
      );
  }
}

모든 타입을 처리했다면 `default`까지 도달할 타입이 없습니다.

따라서:

shape

는 `never`가 됩니다.

TypeScript 공식 문서도 판별 유니언의 모든 경우를 처리했는지 확인하는 방법으로 `never`를 이용한 exhaustive checking을 설명합니다.

44. 분기를 하나 빼면 어떻게 될까?

`rectangle` 처리를 일부러 제거해보겠습니다.

function getArea(
  shape: Shape
): number {
  switch (shape.kind) {
    case "circle":
      return (
        Math.PI *
        shape.radius ** 2
      );

    case "square":
      return (
        shape.size ** 2
      );

    case "triangle":
      return (
        shape.width *
        shape.height /
        2
      );

    default:
      return assertNever(
        shape
      );
  }
}

이제 `default`까지 도달 가능한 타입이 있습니다.

Rectangle

하지만 `assertNever()`는:

never

만 받습니다.

따라서 TypeScript가 오류를 알려줍니다.

즉 새로운 타입을 추가하면서 처리 코드를 빠뜨렸다는 사실을 컴파일 단계에서 발견할 수 있습니다.

이건 꽤 유용합니다.

타입 하나 추가했더니 TypeScript가 관련 분기들을 찾아다니며:

“여기도 수정하셔야 하는데요?”

라고 알려주는 셈입니다.

45. `never`를 변수로 직접 검사하는 방법

함수를 만들지 않고 다음처럼 작성하는 방식도 있습니다.

function getArea(
  shape: Shape
): number {
  switch (shape.kind) {
    case "circle":
      return (
        Math.PI *
        shape.radius ** 2
      );

    case "square":
      return (
        shape.size ** 2
      );

    case "triangle":
      return (
        shape.width *
        shape.height /
        2
      );

    case "rectangle":
      return (
        shape.width *
        shape.height
      );

    default: {
      const exhaustiveCheck:
        never = shape;

      return exhaustiveCheck;
    }
  }
}

모든 경우를 처리했다면 `shape`가 `never`가 되어야 합니다.

처리하지 않은 타입이 남아 있다면 오류가 발생합니다.

46. 판별 유니언과 `never`가 좋은 이유

단순한 `if`문보다 코드가 길어 보일 수 있습니다.

하지만 규모가 커질수록 장점이 드러납니다.

예를 들어:

결제 상태
주문 상태
업로드 상태
WebSocket 상태
사용자 인증 상태
UI 모달 상태
비동기 요청 상태

같이 “정해진 몇 가지 상태 중 하나”를 표현하는 코드가 많습니다.

새 상태를 추가했을 때 처리하지 않은 곳을 찾는 것은 생각보다 어렵습니다.

판별 유니언과 `never`를 사용하면 타입 검사기가 그 일을 도와줄 수 있습니다.

47. 객체 타입을 구분하는 네 가지 도구 비교

지금까지 네 가지 방법을 만났습니다.

`typeof`

typeof value === "string"

주로 원시 타입을 구분합니다.

string
number
boolean
bigint
symbol
undefined
function
object

객체끼리의 세부 구분에는 한계가 있습니다.

`in`

"permissions" in account

특정 속성의 존재 여부로 객체 타입을 구분합니다.

일반 객체 데이터에 유용합니다.

`instanceof`

error instanceof Error

클래스나 생성자 계열로 객체를 구분합니다.

`Date`, `Error`, 직접 만든 클래스 등을 다룰 때 자연스럽습니다.

사용자 정의 타입 가드

function isAdmin(
  account: Account
): account is Admin {
  // ...
}

복잡한 판별 로직을 함수로 재사용할 수 있습니다.

판별 유니언

type:
  | "card"
  | "bank"

객체 설계 단계부터 구분용 속성을 넣습니다.

상태나 명확한 객체 종류를 모델링할 때 특히 좋습니다.

48. 어떤 방법이 가장 좋은가?

항상 하나가 더 좋은 것은 아닙니다.

다음 상황을 생각해보면 선택이 쉬워집니다.

원시 타입을 구분한다

string | number

`typeof`가 자연스럽습니다.

기존 객체 구조를 구분한다

User | Admin

특정 속성이 명확하다면 `in`을 사용할 수 있습니다.

클래스 인스턴스를 구분한다

Date | string

`instanceof`가 자연스럽습니다.

검사 로직이 길거나 여러 곳에서 재사용한다

사용자 정의 타입 가드를 고려합니다.

내가 타입 구조를 직접 설계할 수 있다

판별 유니언을 적극적으로 고려할 만합니다.

49. 실습 1: 직원과 관리자 구분하기

interface Employee {
  id: number;
  name: string;
}

interface Manager {
  id: number;
  name: string;
  teamMembers: string[];
}
type CompanyMember =
  Employee | Manager;

`in`을 사용합니다.

function printMember(
  member: CompanyMember
): void {
  if (
    "teamMembers" in member
  ) {
    console.log(
      `${member.name} 팀장`
    );

    console.log(
      `팀원 수: ${
        member.teamMembers.length
      }`
    );

    return;
  }

  console.log(
    `${member.name} 직원`
  );
}

50. 실습 2: 오류 메시지 안전하게 만들기

function getErrorMessage(
  error: unknown
): string {
  if (
    error instanceof Error
  ) {
    return error.message;
  }

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

  if (
    typeof error === "object" &&
    error !== null &&
    "message" in error &&
    typeof error.message ===
      "string"
  ) {
    return error.message;
  }

  return "알 수 없는 오류";
}

여러 narrowing 방법이 자연스럽게 함께 사용됩니다.

unknown

↓ instanceof
Error

↓ typeof
string

↓ typeof + null 검사 + in
message를 가진 객체

51. 실습 3: API 응답 타입 가드

interface User {
  id: number;
  name: string;
  email: string;
}
function isUser(
  value: unknown
): value is User {
  if (
    typeof value !== "object" ||
    value === null
  ) {
    return false;
  }

  return (
    "id" in value &&
    typeof value.id ===
      "number" &&

    "name" in value &&
    typeof value.name ===
      "string" &&

    "email" in value &&
    typeof value.email ===
      "string"
  );
}

사용합니다.

const response:
  unknown =
    JSON.parse(jsonText);
if (
  !isUser(response)
) {
  throw new Error(
    "잘못된 사용자 데이터입니다."
  );
}

이 아래에서는:

response

가 `User`입니다.

console.log(
  response.name
);

52. 실습 4: 파일 업로드 상태

파일 업로드 상태를 판별 유니언으로 만들어 보겠습니다.

type UploadState =
  | {
      status: "idle";
    }
  | {
      status: "uploading";
      progress: number;
    }
  | {
      status: "success";
      fileUrl: string;
    }
  | {
      status: "error";
      message: string;
    };

상태 메시지를 만듭니다.

function getUploadMessage(
  state: UploadState
): string {
  switch (state.status) {
    case "idle":
      return (
        "파일을 선택해 주세요."
      );

    case "uploading":
      return (
        `업로드 중 ${
          state.progress
        }%`
      );

    case "success":
      return (
        `업로드 완료: ${
          state.fileUrl
        }`
      );

    case "error":
      return (
        `업로드 실패: ${
          state.message
        }`
      );
  }
}

`progress`는 uploading 상태에서만 존재합니다.

`fileUrl`은 success에서만 존재합니다.

`message`는 error에서만 존재합니다.

타입 자체가 상태별 데이터 규칙을 설명합니다.

53. 실습 5: 주문 상태 모델링

type OrderState =
  | {
      status: "pending";
      orderId: string;
    }
  | {
      status: "paid";
      orderId: string;
      paidAt: string;
    }
  | {
      status: "shipping";
      orderId: string;
      trackingNumber: string;
    }
  | {
      status: "delivered";
      orderId: string;
      deliveredAt: string;
    }
  | {
      status: "cancelled";
      orderId: string;
      reason: string;
    };

주문 상태를 출력합니다.

function printOrderState(
  order: OrderState
): string {
  switch (order.status) {
    case "pending":
      return "결제 대기";

    case "paid":
      return (
        `결제 완료: ${
          order.paidAt
        }`
      );

    case "shipping":
      return (
        `배송 중: ${
          order.trackingNumber
        }`
      );

    case "delivered":
      return (
        `배송 완료: ${
          order.deliveredAt
        }`
      );

    case "cancelled":
      return (
        `주문 취소: ${
          order.reason
        }`
      );
  }
}

이 구조에서는 `"shipping"` 상태인데 운송장 번호가 없는 객체를 만들기 어렵습니다.

54. 자주 하는 실수 1: 모든 객체를 `typeof`로 구분하려고 하기

if (
  typeof account === "object"
) {
  // Admin?
}

`User`도 `Admin`도 객체이므로 충분하지 않습니다.

객체 내부의 구분 가능한 속성을 확인합니다.

if (
  "permissions" in account
) {
}

55. 자주 하는 실수 2: interface에 `instanceof` 사용하기

interface User {
  name: string;
}
value instanceof User

불가능합니다.

`interface`는 런타임에 존재하지 않습니다.

클래스라면 가능합니다.

class User {
}
value instanceof User

56. 자주 하는 실수 3: 타입 가드의 반환 타입만 믿고 검사는 대충 만들기

function isUser(
  value: unknown
): value is User {
  return true;
}

TypeScript는 조건문에서 이를 믿게 됩니다.

하지만 실제 런타임 값은 검증되지 않았습니다.

타입 가드의 반환 타입보다 함수 안의 실제 판별 코드가 더 중요합니다.

57. 자주 하는 실수 4: `as`로 타입 가드를 대신하기

const user =
  data as User;

간단하지만 실제 검증은 없습니다.

가능하다면:

if (isUser(data)) {
}

처럼 실제 값의 구조를 확인합니다.

특히 외부 API, 파일, 사용자 입력처럼 신뢰할 수 없는 데이터 경계에서는 이 차이가 중요합니다.

58. 자주 하는 실수 5: 판별 속성을 일반 `string`으로 만들기

interface Circle {
  kind: string;
  radius: number;
}

이렇게 하면 `kind`가 타입을 구분하는 리터럴 신분증 역할을 하지 못합니다.

다음처럼 작성합니다.

interface Circle {
  kind: "circle";
  radius: number;
}
interface Square {
  kind: "square";
  size: number;
}

59. 자주 하는 실수 6: 상태를 boolean 여러 개로 표현하기

예를 들어:

interface RequestState {
  isLoading: boolean;
  isSuccess: boolean;
  isError: boolean;
}

이 구조에서는 이런 상태가 가능합니다.

{
  isLoading: true,
  isSuccess: true,
  isError: true
}

세 상태가 동시에 참입니다.

실제로 가능한 상태가 하나뿐이라면 판별 유니언이 더 명확할 수 있습니다.

type RequestState =
  | {
      status: "loading";
    }
  | {
      status: "success";
    }
  | {
      status: "error";
    };

60. 자주 하는 실수 7: `default`에서 아무 생각 없이 처리하기

switch (shape.kind) {
  case "circle":
    // ...
    break;

  case "square":
    // ...
    break;

  default:
    return 0;
}

새로운 도형이 추가되어도 `default`가 조용히 처리해버릴 수 있습니다.

모든 경우를 반드시 처리해야 하는 코드라면 `never`를 이용한 exhaustive checking을 고려할 수 있습니다.

61. 실무에서는 판별 유니언을 어디에 많이 쓸까?

대표적인 예를 몇 개만 살펴보겠습니다.

비동기 요청 상태

idle
loading
success
error

로그인 상태

checking
authenticated
guest

결제 수단

card
bank
point

알림 타입

info
warning
error
success

WebSocket 상태

connecting
connected
disconnected
error

업로드 상태

idle
uploading
success
error

이벤트 메시지

userCreated
userUpdated
userDeleted

“여러 종류 중 정확히 하나”라는 구조라면 판별 유니언을 떠올려볼 만합니다.

62. 타입 좁히기 선택 가이드

복잡하게 외우기보다 상황에 따라 생각해보면 됩니다.

원시 타입인가?

typeof value === "string"

`typeof`

특정 속성의 존재 여부로 객체를 구분할 수 있는가?

"permissions" in user

`in`

클래스 인스턴스인가?

value instanceof Date

`instanceof`

판별 코드가 여러 곳에서 반복되는가?

isUser(value)

사용자 정의 타입 가드

객체 구조를 처음부터 설계할 수 있는가?

status: "success"

판별 유니언

모든 유니언 구성원을 반드시 처리해야 하는가?

never

를 이용한 완전성 검사

63. 미니 퀴즈

문제 1

다음 함수에서 `permissions`를 안전하게 사용하려면 어떤 연산자를 사용할 수 있을까요?

function test(
  user: User | Admin
) {
  // ?
}

정답

if (
  "permissions" in user
) {
  user.permissions;
}

문제 2

`Error` 객체인지 확인하기 좋은 방법은?

정답

error instanceof Error

문제 3

다음 코드는 왜 안 될까요?

interface User {
  name: string;
}

value instanceof User;

정답

`User`는 `interface`이며 런타임 JavaScript 값으로 존재하지 않기 때문입니다.

문제 4

다음 반환 타입은 무엇을 의미할까요?

value is User

정답

해당 함수가 `true`를 반환한 경우 `value`를 `User` 타입으로 좁힐 수 있다는 타입 프레디킷입니다.

문제 5

다음 타입에서 판별 속성은 무엇일까요?

type Result =
  | {
      status: "success";
      data: string;
    }
  | {
      status: "error";
      message: string;
    };

정답

status

입니다.

문제 6

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

if (
  result.status ===
  "success"
) {
  // result?
}

정답

{
  status: "success";
  data: string;
}

으로 좁혀집니다.

문제 7

판별 유니언에서 리터럴 타입이 중요한 이유는?

정답

각 유니언 구성원을 특정 값으로 명확하게 구분할 수 있기 때문입니다.

status: "success"
status: "error"

처럼 값 자체가 타입 판별 기준이 됩니다.

문제 8

새로운 유니언 타입이 추가되었을 때 처리 누락을 찾는 데 사용할 수 있는 타입은?

정답

never

입니다.

문제 9

다음 타입 가드의 문제점은 무엇일까요?

function isUser(
  value: unknown
): value is User {
  return true;
}

정답

실제 값의 구조를 전혀 검사하지 않는데도 TypeScript에게 `User`라고 알려주므로 런타임에서 잘못된 타입을 사용할 위험이 있습니다.

문제 10

API 요청 상태를 표현할 때 다음 두 방식 중 어떤 것이 불가능한 상태를 막기 쉬울까요?

interface State {
  loading: boolean;
  data?: User;
  error?: string;
}

또는:

type State =
  | {
      status: "loading";
    }
  | {
      status: "success";
      data: User;
    }
  | {
      status: "error";
      error: string;
    };

정답

두 번째 판별 유니언 방식입니다.

상태에 필요한 데이터 구조를 각각 연결할 수 있기 때문입니다.

64. 이번 편 핵심 정리

원시 타입은 `typeof`로 상당 부분 구분할 수 있습니다.

typeof value === "string"

하지만 객체끼리는 대부분:

object

이기 때문에 다른 증거가 필요합니다.

특정 속성이 있는지 확인하려면:

"permissions" in user

`in`을 사용합니다.

클래스 인스턴스라면:

error instanceof Error

`instanceof`를 사용할 수 있습니다.

복잡한 검사를 함수로 재사용하려면:

function isUser(
  value: unknown
): value is User {
  // 실제 검사
}

사용자 정의 타입 가드를 만들 수 있습니다.

그리고 객체 타입을 직접 설계할 수 있다면:

type Result =
  | {
      status: "success";
      data: User;
    }
  | {
      status: "error";
      message: string;
    };

처럼 판별 속성을 두는 방법이 매우 강력합니다.

모든 경우를 빠짐없이 처리해야 한다면:

never

를 활용할 수 있습니다.

65. `in`, `instanceof`, 타입 가드, 판별 유니언 한눈에 보기

방법주로 사용하는 상황
`typeof`원시 타입 구분`typeof value === "string"`
`in`객체의 특정 속성 존재 확인`"permissions" in user`
`instanceof`클래스 인스턴스 확인`error instanceof Error`
타입 가드복잡한 판별 로직 재사용`value is User`
판별 유니언타입 구조를 직접 설계 가능할 때`status: "success"`
`never`모든 유니언 경우 처리 확인exhaustive checking

외울 필요는 없습니다.

코드를 보면서 이렇게 생각하면 됩니다.

이 값을 런타임에서
무엇으로 확인할 수 있지?

그 질문이 타입 좁히기의 출발점입니다.

66. 마무리

이번 편의 시작에서는 이런 문제가 있었습니다.

User | Admin

둘 다:

typeof value === "object"

였기 때문에 `typeof`만으로는 구분하기 어려웠습니다.

그래서 객체 내부의 실제 속성을 확인했습니다.

"permissions" in account

클래스 객체라면 생성자 관계를 이용했습니다.

value instanceof Error

반복되는 검사에는 이름을 붙였습니다.

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

그리고 마지막에는 한 단계 더 나아갔습니다.

“객체를 만든 다음 어떻게 구분하지?”만 고민하는 대신 처음부터 구분하기 좋은 구조로 타입을 설계했습니다.

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

이 구조의 장점은 단순히 타입 좁히기가 편해지는 것만이 아닙니다.

코드를 읽는 사람도 객체의 상태를 바로 알 수 있고, 존재하면 안 되는 상태 조합을 타입 단계에서 줄일 수 있습니다.

그리고 새로운 타입을 추가했을 때 처리 누락까지 검사하고 싶다면 `never`를 활용할 수 있습니다.

function assertNever(
  value: never
): never {
  throw new Error(
    "처리되지 않은 타입"
  );
}

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

좋은 타입 좁히기는 TypeScript를 설득하는 기술이 아니라, 실제 런타임 증거와 타입 정보를 일치시키는 과정이다.

처음에는 TypeScript가 꽤 귀찮게 구는 것처럼 느껴질 수 있습니다.

이거 User 맞아요.

TypeScript:
증거 있나요?

Admin 아니에요.

TypeScript:
증거 있나요?

진짜 맞다니까요.

TypeScript:
증거를 제출하세요.

그런데 프로젝트가 커지기 시작하면 이 고집스러움이 꽤 든든해집니다.

`in`, `instanceof`, 타입 가드, 판별 유니언은 결국 TypeScript에게 제대로 된 증거를 건네는 방법입니다.

그리고 판별 유니언까지 익숙해지고 나면 한 가지 감각이 생깁니다.

타입 오류를 나중에 피하는 것보다, 잘못된 상태를 처음부터 만들기 어렵게 설계하는 편이 훨씬 편하다.

이 감각이 생기기 시작하면 TypeScript가 조금 다르게 보이기 시작합니다.

단순히 JavaScript에 타입을 붙이는 언어가 아니라, 프로그램의 상태와 규칙을 코드 안에 표현하는 도구로 말입니다.

67. 다음 편에서는

이번 편까지 오면서 함수 곳곳에서 이런 코드를 사용했습니다.

function isAdmin(
  account: User | Admin
): account is Admin {
  return (
    "permissions" in account
  );
}
function getArea(
  shape: Shape
): number {
  // ...
}
function getErrorMessage(
  error: unknown
): string {
  // ...
}

그런데 아직 함수 자체의 타입에 대해서는 본격적으로 파고들지 않았습니다.

JavaScript에서 함수는 값입니다.

const add = (
  a: number,
  b: number
) => {
  return a + b;
};

그렇다면 함수 자체에도 타입을 붙일 수 있을까요?

물론 가능합니다.

type Calculator = (
  a: number,
  b: number
) => number;

콜백 함수의 타입은 어떻게 작성할까요?

function calculate(
  a: number,
  b: number,
  operation: Calculator
) {
  return operation(a, b);
}

선택적 매개변수와 기본 매개변수는 어떤 차이가 있을까요?

function greet(
  name?: string
) {
}

나머지 매개변수는 어떻게 타입을 지정할까요?

function sum(
  ...numbers: number[]
) {
}

그리고 하나의 함수가 호출 방법에 따라 다른 타입을 반환한다면 어떻게 표현해야 할까요?

getData(1);
getData("USER-001");

이제 객체 수사를 마치고 TypeScript에서 가장 자주 사용하는 구성 요소 중 하나인 함수로 무대를 옮겨보겠습니다.

다음 편에서는 함수의 매개변수와 반환 타입부터 콜백, 함수 타입 별칭까지 차근차근 이어가겠습니다.

댓글

0

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