자바스크립트엔 열거형(enum, 정해진 값 몇 개에 이름을 붙인 묶음)이라는 게 없다. 요일, 주문 상태, 권한 등급처럼 "정해진 몇 개 중 하나"인 값을 자바스크립트에선 그냥 문자열이나 숫자로 대충 표현하다가 오타 한 번에 조용히 터지곤 했다. 타입스크립트는 이걸 두 가지 방법으로 풀어준다. 하나는 enum이고, 다른 하나는 리터럴 타입이다. 둘 다 "이 목록 중 하나만 허용"을 강제하는데 성격이 꽤 달라서, 실무에선 어느 쪽을 쓸지 매번 갈린다. 여기 나오는 코드는 전부 tsc(타입스크립트 컴파일러)로 돌려 확인한 것이고, 타입 에러도 실제로 나온 메시지 그대로다.
숫자 enum은 번호를 알아서 매긴다. enum에 값을 안 적으면 위에서부터 0, 1, 2 순으로 번호가 자동으로 붙는다.
enum Direction { Up, Down, Left, Right }
console.log(Direction.Up); // 0
console.log(Direction.Right); // 3
첫 멤버에 Up = 1처럼 값을 주면 그 뒤가 2, 3, 4로 이어진다. 편해 보이지만, 이 숫자를 로그로 찍으면 0이나 3만 나와서 그게 뭘 뜻하는지 알 수가 없다. 그래서 나는 숫자 enum을 별로 안 쓴다.
문자열 enum은 값을 직접 박는다. 각 멤버에 문자열을 직접 지정하는 방식이다. 로그에 값이 그대로 찍혀 의미가 바로 보인다.
enum Status {
Active = "ACTIVE",
Done = "DONE",
}
console.log(Status.Active); // "ACTIVE"
대신 문자열 enum엔 자동 번호가 없어 멤버마다 값을 다 적어줘야 한다. 그리고 값이 같아 보인다고 문자열을 직접 대입하면 막힌다.
const s: Status = "ACTIVE";
// error TS2322: Type '"ACTIVE"' is not assignable to type 'Status'.
반드시 Status.Active를 거쳐야 한다. 문자열 "ACTIVE"와 enum 멤버는 타입스크립트 눈엔 다른 물건이다. 이 깐깐함이 문자열 enum의 장점이자 동시에 사람 짜증나게 하는 지점이다.
숫자 enum은 뒤로도 매핑된다. 숫자 enum엔 잘 알려진 함정이 하나 있다. 컴파일하면 이름에서 숫자로 가는 길뿐 아니라 숫자에서 이름으로 가는 길까지 양방향으로 만들어진다(역매핑).
enum Color { Red, Green, Blue }
console.log(Color[0]); // "Red"
console.log(Color.Red); // 0
멤버는 셋인데 실제 객체 안엔 키가 여섯 개 들어 있다. Object.keys(Color)를 찍으면 ['0','1','2','Red','Green','Blue']가 나온다. 이걸 모르고 enum을 통째로 돌면 값이 두 배로 나와서 당황한다. 문자열 enum엔 역매핑이 없어 이 문제가 아예 없다.
const enum은 흔적을 안 남긴다. 보통 enum은 컴파일한 뒤에도 객체로 살아남아 번들(브라우저로 보내는 최종 파일 묶음)에 실린다. const enum은 컴파일할 때 쓰인 자리를 값으로 갈아치우고 자기 자신은 사라진다.
const enum Size { S = 1, M = 2, L = 3 }
const box = Size.M;
// 컴파일 결과: const box = 2 /* Size.M */;
런타임 객체가 없어 가볍다. 대신 값이 코드 곳곳에 숫자로 박혀버려 나중에 바꿔 끼우기 번거롭고, 파일을 따로따로 컴파일하는 빌드 도구와는 궁합이 나쁘다. 나는 라이브러리로 나갈 코드엔 안 쓴다.
리터럴 타입은 값 하나가 곧 타입이다. 여기서부터가 enum의 경쟁자다. 타입스크립트는 "left"라는 특정 문자열, 42라는 특정 숫자를 그 자체로 타입으로 삼을 수 있다.
let dir: "left";
dir = "left"; // OK
dir = "up";
// error TS2322: Type '"up"' is not assignable to type '"left"'.
값 하나짜리 타입은 딱 그 값만 받는다. 혼자선 쓸모없어 보이지만, 유니온(union, 여러 타입을 세로줄로 묶어 그중 하나를 뜻함)과 만나면 판이 달라진다.
유니온으로 이 중 하나를 만든다. 리터럴 여러 개를 세로줄(|)로 이으면 "이 목록 중 하나"라는 타입이 된다. enum 없이 같은 걸 표현하는 방법이다.
type Dir = "up" | "down" | "left" | "right";
function move(d: Dir) { /* ... */ }
move("left"); // OK
move("north");
// error TS2345: Argument of type '"north"' is not assignable to parameter of type 'Dir'.
편집기 자동완성도 이 네 개를 정확히 띄워준다. 따로 import(다른 파일에서 값을 불러오는 문법)할 것 없이 문자열만 넘기면 되니, 실무에선 이 방식을 훨씬 많이 쓴다.
as const로 넓어지는 걸 막는다. 리터럴 타입엔 발등 찍는 함정이 있다. 값을 변수나 객체에 담으면 타입스크립트가 타입을 슬그머니 넓혀버린다(타입 넓히기).
const mode = "dark"; // 타입: "dark"
let mode2 = "dark"; // 타입: string
const obj = { mode: "dark" }; // obj.mode의 타입: string
const 변수 하나는 "dark"로 좁게 잡히지만, let이나 객체 속에 들어가면 그냥 string으로 넓어진다. 그래서 이걸 좁은 함수에 넘기면 터진다.
function setMode(m: "dark" | "light") {}
const config = { mode: "dark" };
setMode(config.mode);
// error TS2345: Argument of type 'string' is not assignable to parameter of type '"dark" | "light"'.
분명 "dark"를 넣었는데 왜 안 되냐며 나는 이걸로 30분을 날린 적 있다. 객체 뒤에 as const를 붙이면 안쪽 값까지 리터럴로 굳어 이 문제가 사라진다.
const config = { mode: "dark" } as const;
setMode(config.mode); // OK
enum과 리터럴 유니온, 뭘 쓰나. 대부분은 리터럴 유니온이 답이다. 가볍고, 값 그대로라 서버가 보내준 문자열과 바로 맞아떨어진다. enum은 값에 이름을 붙여 한군데서 관리하고 싶을 때 쓸 만한데, 숫자 enum의 역매핑과 번들 무게가 걸려서 나는 문자열 enum이나 as const 객체를 고른다. 실제로 요즘 코드베이스에서 자주 보는 건 객체에 as const를 붙이고 거기서 유니온 타입을 뽑아내는 패턴이다.
const ROLE = { Admin: "admin", User: "user" } as const;
type Role = typeof ROLE[keyof typeof ROLE]; // "admin" | "user"
이러면 런타임에서 쓸 값 객체(ROLE)와 컴파일 때 쓸 타입(Role)을 한 번에 얻는다. enum이 주던 "이름 관리"와 리터럴 유니온이 주던 "가벼움"을 둘 다 챙기는 셈이다. 셋 중 뭘 쓸지 헷갈리면, 값 목록이 서버 코드값과 엮여 있으면 이 패턴을, 화면 안에서만 도는 몇 글자짜리 옵션이면 그냥 리터럴 유니온을 쓰면 대체로 맞다.