TypeScript Enum과 Union String Literal

1. 개요

주문 상태나 결제 방법처럼 고정된 값의 집합을 표현할 때, TypeScript는 enum과 union string literal('PENDING' | 'PAID')이라는 두 가지 인기 있는 옵션을 제공합니다. 두 방식은 에디터에서는 비슷해 보이지만 runtime, type checker 처리 방식, 최신 tooling과의 호환성 면에서 다릅니다.

이 글에서는 구체적인 예제로 각 방식을 분석하고, 흔히 하는 실수들을 지적하며, 두 방식의 장점을 모두 활용하는 as const 패턴을 소개합니다. 읽고 나면 프로젝트에 맞는 선택 기준이 명확해질 것입니다.

2. TypeScript의 Enum

2.1. String enum

enum OrderStatus {
  Pending = 'PENDING',
  Paid = 'PAID',
  Shipped = 'SHIPPED',
  Cancelled = 'CANCELLED',
}

function canCancel(status: OrderStatus): boolean {
  return status === OrderStatus.Pending || status === OrderStatus.Paid;
}

console.log(canCancel(OrderStatus.Paid));
console.log(Object.values(OrderStatus));

npx tsx order.ts로 실행하면 다음을 얻습니다:

true
[ 'PENDING', 'PAID', 'SHIPPED', 'CANCELLED' ]

중요한 점: enum은 단순한 type이 아닙니다. 컴파일러는 runtime에 실제 object를 생성합니다. 이것이 Object.values(OrderStatus)를 호출할 수 있는 이유입니다. 생성되는 JavaScript 코드는 다음과 같습니다:

var OrderStatus;
(function (OrderStatus) {
  OrderStatus["Pending"] = "PENDING";
  OrderStatus["Paid"] = "PAID";
  OrderStatus["Shipped"] = "SHIPPED";
  OrderStatus["Cancelled"] = "CANCELLED";
})(OrderStatus || (OrderStatus = {}));

2.2. Numeric enum과 역방향 매핑

값을 할당하지 않으면 enum은 0부터 번호를 매깁니다:

enum Priority {
  Low,
  Medium,
  High,
}

console.log(Priority.High);
console.log(Priority[2]);
console.log(Object.keys(Priority));
2
High
[ '0', '1', '2', 'Low', 'Medium', 'High' ]

Numeric enum은 **역방향 매핑(reverse mapping)**을 가집니다: Priority[2]는 'High'를 반환합니다. 결과적으로 Object.keys는 숫자와 이름 모두를 반환합니다. 이것은 enum으로 드롭다운을 렌더링할 때 목록이 중복되어 보이는 경전적인 실수입니다.

주의: Numeric enum은 여전히 모든 number 타입 변수를 받습니다. 예를 들어 const p: Priority = someNumber;는 someNumber가 99여도 정상 컴파일됩니다. TypeScript 5.0부터는 const p: Priority = 99;처럼 범위 밖의 리터럴을 할당하면 에러가 나지만, number 변수는 여전히 통과합니다.

2.3. Enum의 “nominal” 특성

String enum은 대응하는 string literal을 받지 않습니다:

canCancel(OrderStatus.Paid); // OK
canCancel('PAID');           // 에러: Argument of type '"PAID"' is not assignable to parameter of type 'OrderStatus'.

이것을 장점으로 볼 수 있습니다: 모든 곳에서 OrderStatus를 거쳐야 합니다. 하지만 데이터가 API의 JSON에서 오면 type casting(as OrderStatus)을 하거나 변환 함수를 작성해야 합니다.

3. Union string literal

type PaymentMethod = 'CARD' | 'BANK_TRANSFER' | 'COD';

interface Payment {
  orderId: string;
  method: PaymentMethod;
  amount: number;
}

const payment: Payment = { orderId: 'ORD-001', method: 'COD', amount: 250_000 };
console.log(payment.method);

// const wrong: Payment = { orderId: 'ORD-002', method: 'PAYPAL', amount: 1 };
// 에러: Type '"PAYPAL"' is not assignable to type 'PaymentMethod'.
COD

Union type은 compile할 때 완전히 제거됩니다. 결과 JavaScript에는 string 'COD'를 가진 object payment만 남고, 추가 코드는 없습니다. 에디터는 여전히 method: '를 입력할 때 세 가지 값을 모두 제안합니다.

약점: runtime에 존재하지 않기 때문에 PaymentMethod의 값들을 순회할 수 없습니다. 별도의 목록을 선언하지 않으면 input을 검증할 수도 없습니다.

3.1. as const로 값 목록 가져오기

해결책은 먼저 배열을 선언하고 배열에서 type을 유추하는 것입니다:

export const PAYMENT_METHODS = ['CARD', 'BANK_TRANSFER', 'COD'] as const;
export type PaymentMethod = (typeof PAYMENT_METHODS)[number];

export function isPaymentMethod(value: string): value is PaymentMethod {
  return (PAYMENT_METHODS as readonly string[]).includes(value);
}

console.log(PAYMENT_METHODS);
console.log(isPaymentMethod('COD'), isPaymentMethod('PAYPAL'));
[ 'CARD', 'BANK_TRANSFER', 'COD' ]
true false

설명:

  • as const는 각 요소의 literal 타입을 유지합니다. 배열의 타입은 string[]이 아니라 readonly ['CARD', 'BANK_TRANSFER', 'COD']입니다.
  • (typeof PAYMENT_METHODS)[number]는 모든 요소의 union을 가져옵니다, 즉 'CARD' | 'BANK_TRANSFER' | 'COD'입니다.
  • isPaymentMethod는 type guard입니다. 검사 후 TypeScript는 value를 PaymentMethod로 좁힙니다.
  • readonly string[]으로 casting하는 것이 필요합니다. 왜냐하면 tuple literal의 includes는 그 literal들만 받기 때문입니다.

이제부터 목록과 type은 항상 동기화됩니다. 배열에 요소를 추가하면 type이 자동으로 업데이트됩니다.

4. as const object 패턴: 중간의 선택지

많은 팀이 enum의 OrderStatus.Paid 구문은 좋아하지만 단점은 없었으면 합니다. as const object를 사용할 수 있습니다:

export const OrderStatus = {
  Pending: 'PENDING',
  Paid: 'PAID',
  Shipped: 'SHIPPED',
  Cancelled: 'CANCELLED',
} as const;

export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];

function canCancel(status: OrderStatus): boolean {
  return status === OrderStatus.Pending || status === OrderStatus.Paid;
}

console.log(canCancel(OrderStatus.Paid));
console.log(canCancel('SHIPPED'));
true
false

여기서:

  • 같은 이름 OrderStatus를 value(object)와 type 모두에 사용합니다. TypeScript는 value와 type이 다른 namespace에 있기 때문에 이를 허용합니다.
  • keyof typeof OrderStatus는 'Pending' | 'Paid' | ...입니다. Indexed access는 값들의 union인 'PENDING' | 'PAID' | ...을 반환합니다.
  • OrderStatus.Paid를 쓸 수 있으면서도, JSON에서 'SHIPPED'를 직접 전달할 수 있고 casting이 필요 없습니다.
  • 생성되는 코드는 단순한 object literal일 뿐, IIFE도 없고 역방향 매핑도 없습니다.

5. never를 사용한 Exhaustive checking

Enum과 union 모두 ’모든 경우를 처리했는가’를 확인하는 것을 지원합니다. 이것이 string 대신 고정된 값의 집합을 사용하는 주요 이유입니다.

import { OrderStatus } from './order-status';

export function statusLabel(status: OrderStatus): string {
  switch (status) {
    case 'PENDING':
      return '결제 대기 중';
    case 'PAID':
      return '결제됨';
    case 'SHIPPED':
      return '배송 중';
    case 'CANCELLED':
      return '취소됨';
    default: {
      const unreachable: never = status;
      throw new Error(`유효하지 않은 상태: ${unreachable}`);
    }
  }
}

나중에 OrderStatus에 Refunded: 'REFUNDED'를 추가했는데 case를 추가하지 않으면, const unreachable: never = status; 줄에서 에러가 나타납니다:

Type 'string' is not assignable to type 'never'.

더 정확하게는 메시지가 '"REFUNDED"' is not assignable to type 'never'라고 말할 것입니다. 에러는 compile 시점에 나타나므로 production에 올리기 전에 발견됩니다. throw 분기는 여전히 runtime에서 유용하며, 외부에서 잘못된 데이터가 들어올 때입니다.

6. 핵심 동작에 대한 test 작성

Vitest 설치:

npm install -D vitest

type guard와 statusLabel 함수에 대한 test:

import { describe, expect, it } from 'vitest';
import { isPaymentMethod, PAYMENT_METHODS } from './payment-method';
import { statusLabel } from './status-label';
import { OrderStatus } from './order-status';

describe('PaymentMethod', () => {
  it('목록의 모든 값을 수용합니다', () => {
    for (const method of PAYMENT_METHODS) {
      expect(isPaymentMethod(method)).toBe(true);
    }
  });

  it('낯선 값을 거부합니다', () => {
    expect(isPaymentMethod('PAYPAL')).toBe(false);
    expect(isPaymentMethod('cod')).toBe(false);
  });
});

describe('statusLabel', () => {
  it('모든 상태에 대한 레이블을 반환합니다', () => {
    const labels = Object.values(OrderStatus).map(statusLabel);
    expect(labels).toEqual(['결제 대기 중', '결제됨', '배송 중', '취소됨']);
  });

  it('runtime 데이터가 유효하지 않을 때 에러를 던집니다', () => {
    expect(() => statusLabel('REFUNDED' as OrderStatus)).toThrow('유효하지 않은 상태: REFUNDED');
  });
});

npx vitest run 실행:

 ✓ PaymentMethod > 목록의 모든 값을 수용합니다
 ✓ PaymentMethod > 낯선 값을 거부합니다
 ✓ statusLabel > 모든 상태에 대한 레이블을 반환합니다
 ✓ statusLabel > runtime 데이터가 유효하지 않을 때 에러를 던집니다

 Test Files  1 passed (1)
      Tests  4 passed (4)

두 번째 test는 type guard가 대문자와 소문자를 구분한다는 것을 보여줍니다. 이것은 form input을 parse할 때 놓치기 쉬운 세부 사항입니다.

7. 최신 tooling과 const enum

점점 더 많은 도구들이 TypeScript를 완전히 컴파일하지 않고 type만 제거합니다. 예를 들어: Node.js의 type stripping 모드, 또는 esbuild와 SWC가 각 파일을 개별적으로 처리할 때입니다. 이런 도구들은 enum처럼 runtime 코드를 생성하는 구문을 처리할 수 없습니다.

TypeScript 5.8은 이런 종류의 구문을 금지하는 erasableSyntaxOnly 플래그가 있습니다:

{
  "compilerOptions": {
    "erasableSyntaxOnly": true,
    "isolatedModules": true
  }
}

이 플래그를 활성화하면 모든 enum 선언에서 에러가 나타납니다. Union literal과 as const object는 여전히 유효합니다. 왜냐하면 이들은 순수 JavaScript에 type annotation만 더한 것이기 때문입니다.

const enum은 한때 ‘비용 없는’ enum으로 홍보되었습니다. 컴파일러가 object를 생성하지 않고 값을 inline하기 때문입니다. 하지만 이것은 많은 문제를 일으킵니다:

  • isolatedModules과 안전하게 사용할 수 없습니다. 파일 단위 컴파일 도구는 다른 파일의 enum 값을 모르기 때문입니다.
  • 라이브러리를 배포할 때, .d.ts 파일의 const enum은 사용자가 tsc를 사용하지 않으면 그들의 코드를 망칠 수 있습니다.

주의: 새 코드에서는 const enum을 피하세요. 특히 라이브러리에서요. Runtime 비용 없이 하고 싶다면 union literal이 위험 없이 그것을 할 수 있습니다.

8. 언제 어떤 방식을 사용할까?

기준 enum Union literal Object as const
Runtime에 값이 있음 있음 없음 (별도 배열 필요) 있음
JSON에서 원본 문자열 수용 casting 필요 가능 가능
Type stripping 호환 아니오 가능 가능
X.Value 구문 가능 불가능 가능
Exhaustive check 가능 가능 가능

실용적인 조언:

  • Union literal: 대부분의 경우 기본값. 특히 props, DTO, 함수 매개변수의 type에 좋습니다. 가장 간결하고 runtime 코드가 없습니다.
  • 배열 as const + union: 값을 순회해야 할 때 (드롭다운, input 검증) 또는 validation 라이브러리와 함께 사용할 때입니다.
  • Object as const: 팀이 enum 같은 구문을 원할 때, 또는 코드의 표시 이름이 실제 값과 다를 때 (Pending vs 'PENDING')입니다.
  • enum: 코드베이스가 일관되게 사용하고 있고 tsc로 완전히 빌드할 때, 또는 nominal 특성을 정말 원할 때 (원본 문자열 전달 금지)입니다.

흔한 실수:

  • Numeric enum에 Object.keys를 사용해서 역방향 매핑으로 인한 중복 목록을 받음.
  • Numeric enum이 임의의 number를 받도록 두었다가, enum 중간에 member를 추가하면 DB의 데이터가 어긋남. 순서가 바뀌면 값도 바뀝니다.
  • API에서 데이터를 받을 때 type guard로 검사하지 않고 바로 as OrderStatus로 casting. Type checker는 당신을 믿지만 잘못된 데이터는 계속됩니다.
  • as const를 빠뜨려서 type이 string으로 넓혀지고 모든 효과가 없어짐.

9. 결론

Enum과 union string literal은 모두 ‘고정된 값의 집합’ 문제를 해결합니다. 차이점은 enum은 runtime 코드를 생성하고 nominal 특성이 있다는 것입니다. Union literal은 type 레이어에만 존재하므로 가볍고 현대 tooling과 친화적입니다.

새로운 TypeScript 코드에서는 union literal부터 시작해야 합니다. Runtime에 목록이 필요하면 배열이나 as const object를 추가로 결합하세요. 어느 방식을 선택하든, never로 exhaustive check를 사용하고 시스템의 경계에서 type guard를 사용하세요. 이것이 코드를 실제로 안전하게 만드는 부분입니다.

typescriptenumunion typetype safetybest practices