열거형
열거형 (Enums)
TypeScript에는 JavaScript를 타입 레벨에서 확장한 게 아닌 기능이 몇 가지 있는데, 그중 하나가 바로 열거형(enum)이에요. 열거형은 개발자가 이름 붙은 상수들의 집합을 정의하게 해 줍니다. 의도를 문서화하거나, 서로 구분되는 여러 경우(case)의 집합을 만들 때 유용하죠. TypeScript는 숫자 기반과 문자열 기반, 두 종류의 열거형을 제공합니다.
출처: TypeScript 핸드북
숫자 열거형 (Numeric enums)
먼저 숫자 열거형부터 시작할게요. 다른 언어에서 넘어온 분이라면 아마 더 익숙할 겁니다. 열거형은 enum 키워드로 정의해요.
enum Direction {
Up = 1,
Down,
Left,
Right,
}
위 코드는 Up이 1로 초기화된 숫자 열거형입니다. 이후의 멤버들은 그 지점부터 자동으로 1씩 증가해요. 정리하면 Direction.Up은 1, Down은 2, Left는 3, Right는 4가 되죠.
원한다면 초기화를 아예 생략할 수도 있어요.
enum Direction {
Up,
Down,
Left,
Right,
}
이 경우 Up은 0, Down은 1이 됩니다. 이 자동 증가 동작은 멤버 값 자체에는 신경 쓰지 않고, 같은 열거형 안의 다른 값들과 구분만 되면 되는 상황에서 유용해요.
열거형 사용은 간단합니다. 열거형 자신의 프로퍼티처럼 아무 멤버나 접근하면 되고, 타입은 열거형 이름으로 선언하면 돼요.
enum UserResponse {
No = 0,
Yes = 1,
}
function respond(recipient: string, message: UserResponse): void {
// ...
}
respond("Princess Caroline", UserResponse.Yes);
숫자 열거형은 아래에서 다룰 계산된(const) 멤버와 상수(constant) 멤버와 섞을 수 있습니다. 간단히 말하면, 초기화가 없는 열거형 멤버는 맨 앞에 있거나, 숫자 상수로 초기화된 숫자 열거형 멤버(또는 다른 상수 열거형 멤버) 뒤에 와야 해요. 즉 다음 코드는 허용되지 않습니다.
// @errors: 1061
const getSomeValue = () => 23;
enum E {
A = getSomeValue(),
B,
}
문자열 열거형 (String enums)
문자열 열거형도 비슷한 개념이지만, 아래에 설명할 런타임 차이가 조금 있습니다. 문자열 열거형에서는 각 멤버가 문자열 리터럴 또는 다른 문자열 열거형 멤버로 상수 초기화되어야 해요.
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
문자열 열거형에는 자동 증가 동작이 없지만, 대신 직렬화(serialize)가 잘 된다는 장점이 있어요. 즉, 디버깅하다 숫자 열거형의 런타임 값을 읽어야 할 때 그 값은 불투명해서 그 자체로는 아무 의미도 전달하지 않는 경우가 많죠(물론 역방향 매핑이 도와주기도 해요). 문자열 열거형은 코드가 실행될 때 열거형 멤버 이름과 무관하게 의미 있고 읽기 쉬운 값을 줄 수 있습니다.
이종 열거형 (Heterogeneous enums)
기술적으로 열거형은 문자열 멤버와 숫자 멤버를 섞을 수 있지만, 굳이 그렇게 할 이유가 명확하지 않아요.
enum BooleanLikeHeterogeneousEnum {
No = 0,
Yes = "YES",
}
JavaScript의 런타임 동작을 영리하게 활용하려는 게 아니라면, 이렇게 하지 않기를 권합니다.
계산된 멤버와 상수 멤버 (Computed and constant members)
각 열거형 멤버에는 값이 연결되어 있는데, 그 값은 상수(constant) 또는 계산된(computed) 값일 수 있어요. 열거형 멤버가 상수로 간주되는 경우는 다음과 같습니다.
-
열거형의 첫 번째 멤버이면서 초기화가 없는 경우 → 값
0이 할당돼요.// E.X is constant: enum E { X, } -
초기화가 없고, 바로 앞 열거형 멤버가 숫자 상수인 경우 → 현재 멤버 값은 앞 멤버 값 + 1이 됩니다.
// All enum members in 'E1' and 'E2' are constant. enum E1 { X, Y, Z, } enum E2 { A = 1, B, C, } -
열거형 멤버가 **상수 열거형 표현식(constant enum expression)**으로 초기화된 경우. 상수 열거형 표현식은 컴파일 타임에 완전히 평가될 수 있는 TypeScript 표현식의 부분집합이에요. 다음과 같은 표현식이면 상수 열거형 표현식입니다.
- 리터럴 열거형 표현식(기본적으로 문자열 리터럴이나 숫자 리터럴)
- 이전에 정의된 상수 열거형 멤버에 대한 참조(다른 열거형에서 온 것일 수도)
- 괄호로 묶인 상수 열거형 표현식
- 상수 열거형 표현식에
+,-,~단항 연산자를 적용한 것 - 상수 열거형 표현식을 피연산자로 하는
+,-,*,/,%,<<,>>,>>>,&,|,^이항 연산자
상수 열거형 표현식이
NaN이나Infinity로 평가되면 컴파일 타임 오류입니다.
그 외의 모든 경우 열거형 멤버는 계산된 것으로 간주됩니다.
enum FileAccess {
// constant members
None,
Read = 1 << 1,
Write = 1 << 2,
ReadWrite = Read | Write,
// computed member
G = "123".length,
}
유니온 열거형과 열거형 멤버 타입 (Union enums and enum member types)
계산되지 않는 상수 열거형 멤버 중에 **리터럴 열거형 멤버(literal enum member)**라는 특별한 부분집합이 있어요. 리터럴 열거형 멤버는 초기화 값이 없거나, 다음 값으로 초기화된 상수 열거형 멤버입니다.
- 아무 문자열 리터럴 (예:
"foo","bar","baz") - 아무 숫자 리터럴 (예:
1,100) - 아무 숫자 리터럴에 단항 마이너스를 적용한 것 (예:
-1,-100)
열거형의 모든 멤버가 리터럴 열거형 값을 가지면, 몇 가지 특별한 의미가 생깁니다.
첫째, 열거형 멤버가 타입이 되기도 합니다. 예를 들어 특정 멤버는 열거형 멤버의 값만 가질 수 있다고 선언할 수 있어요.
// @errors: 2322
enum ShapeKind {
Circle,
Square,
}
interface Circle {
kind: ShapeKind.Circle;
radius: number;
}
interface Square {
kind: ShapeKind.Square;
sideLength: number;
}
let c: Circle = {
kind: ShapeKind.Square,
radius: 100,
};
둘째, 열거형 타입 자체가 각 열거형 멤버의 유니언 이 됩니다. 유니온 열거형에서 타입 시스템은 열거형에 존재하는 값의 정확한 집합을 안다는 사실을 활용할 수 있어요. 덕분에 TypeScript는 값을 잘못 비교하는 버그를 잡아낼 수 있습니다. 예를 들어:
// @errors: 2367
enum E {
Foo,
Bar,
}
function f(x: E) {
if (x !== E.Foo || x !== E.Bar) {
//
}
}
이 예시에서 우리는 먼저 x가 E.Foo가 아닌지 확인했어요. 그 검사가 성공하면 ||가 단락(short-circuit)되며 if 본문이 실행됩니다. 하지만 검사가 성공하지 않았다면 x는 E.Foo일 수밖에 없으므로, E.Bar와 같지 않은지 확인하는 건 말이 안 되죠.
런타임에서의 열거형 (Enums at runtime)
열거형은 런타임에 존재하는 실제 객체입니다. 예를 들어 다음 열거형은:
enum E {
X,
Y,
Z,
}
실제로 함수에 전달될 수 있어요.
enum E {
X,
Y,
Z,
}
function f(obj: { X: number }) {
return obj.X;
}
// Works, since 'E' has a property named 'X' which is a number.
f(E);
컴파일 타임에서의 열거형 (Enums at compile time)
열거형은 런타임에 존재하는 실제 객체지만, keyof 키워드는 일반 객체에서 기대하는 것과 다르게 동작합니다. 대신 keyof typeof를 사용해야 모든 열거형 키를 문자열로 나타내는 Type을 얻을 수 있어요.
enum LogLevel {
ERROR,
WARN,
INFO,
DEBUG,
}
/**
* This is equivalent to:
* type LogLevelStrings = 'ERROR' | 'WARN' | 'INFO' | 'DEBUG';
*/
type LogLevelStrings = keyof typeof LogLevel;
function printImportant(key: LogLevelStrings, message: string) {
const num = LogLevel[key];
if (num <= LogLevel.WARN) {
console.log("Log level key is:", key);
console.log("Log level value is:", num);
console.log("Log level message is:", message);
}
}
printImportant("ERROR", "This is a message");
역방향 매핑 (Reverse mappings)
숫자 열거형 멤버는 멤버 이름을 프로퍼티 이름으로 갖는 객체를 만들 뿐 아니라, 열거형 값 → 열거형 이름으로 가는 역방향 매핑 도 갖습니다. 예를 들어 이 예시에서:
enum Enum {
A,
}
let a = Enum.A;
let nameOfA = Enum[a]; // "A"
TypeScript는 이를 다음과 같은 JavaScript로 컴파일합니다.
// @showEmit
enum Enum {
A,
}
let a = Enum.A;
let nameOfA = Enum[a]; // "A"
이 생성된 코드에서 열거형은 정방향(name → value) 매핑과 역방향(value → name) 매핑을 모두 저장하는 객체로 컴파일됩니다. 다른 열거형 멤버에 대한 참조는 항상 프로퍼티 접근으로 내보내지며, 인라인되지 않아요.
문자열 열거형 멤버에는 역방향 매핑이 전혀 생성되지 않는다는 점을 기억하세요.
const 열거형
대부분의 경우 열거형은 충분히 훌륭한 해법입니다. 하지만 때로는 더 엄격한 요구 조건이 있기도 하죠. 열거형 값을 접근할 때 발생하는 추가 코드 생성 비용과 추가적인 간접 참조(indirection)를 피하려면 const 열거형을 쓸 수 있습니다. const 열거형은 열거형에 const 수정자를 붙여 정의해요.
const enum Enum {
A = 1,
B = A * 2,
}
const 열거형은 상수 열거형 표현식만 사용할 수 있고, 일반 열거형과 달리 컴파일 중에 완전히 제거됩니다. const 열거형 멤버는 사용 지점에 인라인됩니다. const 열거형은 계산된 멤버를 가질 수 없기 때문에 이게 가능한 거예요.
const enum Direction {
Up,
Down,
Left,
Right,
}
let directions = [
Direction.Up,
Direction.Down,
Direction.Left,
Direction.Right,
];
이 코드는 생성된 코드에서 이렇게 됩니다.
// @showEmit
const enum Direction {
Up,
Down,
Left,
Right,
}
let directions = [
Direction.Up,
Direction.Down,
Direction.Left,
Direction.Right,
];
const 열거형의 함정
열거형 값을 인라인하는 것은 처음에는 간단해 보이지만, 미묘한 함정이 따라옵니다. 이 함정들은 앰비언트(ambient) const 열거형(기본적으로 .d.ts 파일 안의 const 열거형)과 그것을 프로젝트 간에 공유할 때만 해당되지만, .d.ts 파일을 게시하거나 사용한다면(tsc --declaration이 .ts를 .d.ts로 변환하므로) 이 함정이 여러분에게도 적용될 가능성이 높아요.
isolatedModules문서에 나온 이유 때문에, 그 모드는 근본적으로 앰비언트 const 열거형과 호환되지 않습니다. 즉 앰비언트 const 열거형을 게시하면, 하위 소비자는isolatedModules과 그 열거형 값을 동시에 쓸 수 없어요.- 컴파일 타임에는 의존성의 A 버전 값을 인라인하고, 런타임에는 B 버전을 import 할 수 있습니다. A와 B의 열거형 값이 서로 다를 수 있는데, 조심하지 않으면
if문의 잘못된 분기를 타는 듯한 의외의 버그가 생겨요. 프로젝트를 빌드하는 시점과 거의 같은 시점에 같은 버전의 의존성으로 자동화 테스트를 돌리는 일이 흔해서, 이런 버그를 완전히 놓치기 쉽기 때문에 특히 치명적입니다. importsNotUsedAsValues: "preserve"는 값으로 사용되는 const 열거형의 import를 지우지(elide) 않지만, 앰비언트 const 열거형은 런타임.js파일이 존재한다는 걸 보장하지 않아요. 그래서 풀 수 없는 import가 런타임 오류를 일으킵니다. import를 명확하게 지우는 보통의 방법인 타입 전용 import는 현재 const 열거형 값을 허용하지 않습니다.
이 함정을 피하는 두 가지 방법이 있어요.
- const 열거형을 아예 쓰지 않는다. 린터의 도움으로 const 열거형을 금지할 수 있어요. 당연히 const 열거형과 관련된 모든 문제를 피하지만, 프로젝트가 자기 열거형을 인라인하지 못하게 됩니다. 다른 프로젝트의 열거형을 인라인하는 것과 달리, 자기 프로젝트의 열거형을 인라인하는 것은 문제가 없고 성능상 이점이 있어요.
- 앰비언트 const 열거형을 게시하지 않는다.
preserveConstEnums의 도움으로const를 제거(deconstify)하면 됩니다. 이건 TypeScript 프로젝트 자체가 내부적으로 취한 방식이에요.preserveConstEnums는 const 열거형을 일반 열거형과 같은 JavaScript로 내보냅니다. 그러면 빌드 단계에서.d.ts파일의const수정자를 안전하게 떼어낼 수 있어요.
이렇게 하면 하위 소비자는 여러분 프로젝트의 열거형을 인라인하지 않아서 위 함정을 피하고, 프로젝트 자체는 const 열거형을 완전히 금지하는 경우와 달리 자기 열거형은 여전히 인라인할 수 있습니다.
앰비언트 열거형 (Ambient enums)
앰비언트 열거형은 이미 존재하는 열거형 타입의 형태를 설명할 때 씁니다.
declare enum Enum {
A = 1,
B,
C = 2,
}
앰비언트 열거형과 비앰비언트 열거형의 중요한 차이 하나는, 일반 열거형에서는 앞 멤버가 상수로 간주되면 초기화가 없는 멤버도 상수로 간주된다는 점이에요. 반면, 초기화가 없는 앰비언트(그리고 비-const) 열거형 멤버는 항상 계산된 것으로 간주됩니다.
객체 vs 열거형 (Objects vs Enums)
현대 TypeScript에서는 as const가 붙은 객체로 충분할 때 열거형이 필요하지 않을 수도 있어요.
const enum EDirection {
Up,
Down,
Left,
Right,
}
const ODirection = {
Up: 0,
Down: 1,
Left: 2,
Right: 3,
} as const;
EDirection.Up;
// ^?
ODirection.Up;
// ^?
// Using the enum as a parameter
function walk(dir: EDirection) {}
// It requires an extra line to pull out the values
type Direction = typeof ODirection[keyof typeof ODirection];
function run(dir: Direction) {}
walk(EDirection.Left);
run(ODirection.Right);
이 형식을 TypeScript의 enum보다 선호하는 가장 큰 이유는, 코드베이스를 JavaScript의 현황과 맞춰 유지할 수 있다는 점입니다. 그리고 언젠가 enum이 JavaScript에 추가되면 그 추가 문법으로 옮겨가면 되죠.