Enum vs as const

sloth·2020년 9월 2일
54

Enum vs as const

Typescript에서 가독성을 높이기 위한 일환으로 서로 연관된 상수들을 하나의 namespace에 묶어 관리할 때, 다음과 같이 enum키워드를 사용해 Enum type을 선언하거나

enum COLOR {
  red,
  blue,
  green
}

객체 리터럴에 as const라는 type assertion을 사용합니다.

const COLOR = {
  red: 0,
  blue: 1,
  green: 2
} as const;

두 경우 모두 IDE의 자동완성 기능을 활용할 수 있고, 객체의 프로퍼티들이 모두 readonly로 다뤄지기 때문에 변경의 우려도 없습니다.

위 예제만 본다면 어떤 것을 사용해도 무방하지만, 두 문법은 명확한 차이점이 있습니다.

애초에 목적부터가 다르다

사실 enumas const는 탄생한 목적 자체가 다릅니다. enum은 다른 언어의 Enumeration 문법처럼 서로 연관된 상수들을 하나의 namespace로 묶어 추상화시키기 위해 도입된 것입니다. 이를 통해 코드만 보더라도 의도를 명확히 알 수 있어 가독성이 높일 수 있습니다.

그에 반해 as const는 type assertion의 한 종류로써 리터럴 타입의 추론 범위를 줄이고 값의 재할당을 막기 위한 목적으로 만들어졌습니다.

... When we construct new literal expressions with const assertions, we can signal to the language that

  • no literal types in that expression should be widened (e.g. no going from "hello" to string)
  • object literals get readonly properties
  • array literals become readonly tuples

https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions

추론 범위를 줄인다

const greeting1 = "Hello, World!"; // "Hello, World!"로 추론
let greeting2 = "Hello, World!";   // string으로 추론

Typescript는 const로 선언할 경우, 자동적으로 리터럴 자체로 타입을 추론합니다. 하지만 let으로 선언할 경우, 리터털이 속한 타입으로 타입을 추론합니다. 즉, greeting2라는 변수는 Hello, World!라는 리터럴 타입으로 한정되지 않고, string이라는 상위 타입으로 범위가 확장되는 것입니다.

만약 let으로 선언된 변수의 타입을 리터럴로 한정하고 싶다면,

let greeting2 = "Hello, World!" as const;

와 같이 as const로 type assertion을 해주면 됩니다. const로 선언된 것과 똑같은 효과를 일으키기 때문에 Hello, World로 타입이 추론되고, 변수에 새로운 값을 할당할 수 없습니다. (물론, 위와 같이 쓸바에야 const로 선언하는 것이 더 맞겠죠...)

Object(or Array) 리터럴의 as const

하지만 원시 타입이 아닌 objectarray 타입의 경우라면 얘기가 달라집니다. objectarray는 참조 타입이기 때문에 const로 선언하더라도, 내부 프로퍼티의 추론 범위가 한정되지도 않고, 값을 변경할 수도 있습니다.

const language = {
  korean: "ko",						// string으로 추론
  english: "en",					// string으로 추론
};
language.korean = "kkk";	// kkk로 korean프로퍼티가 변경
language.english = "eee"; // eee로 english프로퍼티가 변경

as const을 사용한다면,

const language = {
  korean: "ko",						// "ko"로 추론, readonly 프로퍼티로 변경
  english: "en",					// "en"로 추론, readonly 프로퍼티로 변경
} as const;

// Error: Cannot assign to 'korean' because it is a read-only property
language.korean = "kkk";
// Error: Cannot assign to 'english' because it is a read-only property.
language.english = "eee";

객체의 모든 프로퍼티들이 readonly로 변경되고, 각 프로퍼티의 타입이 할당된 리터럴 값으로 추론됩니다.

심지어 다음과 같이 중첩된 객체나 배열의 프로퍼티들도readonly로 변경되고 리터럴 값으로 추론됩니다.

const obj = {
  a: 10,
  b: [20, 30],
  c: {
    d: {
      e: {
        greeting: "Hello",
      },
    },
  },
} as const;

// 다음과 같이 추론됨.
const obj: {
  readonly a: 10;
  readonly b: readonly [20, 30];
  readonly c: {
    readonly d: {
      readonly e: {
        readonly greeting: "Hello";
      };
    };
  };
};

이렇듯, as const를 사용하면 원시 타입이든 참조 타입이든 값의 재할당을 막아버리기 때문에 의도치 않은 변경으로 인한 오류를 없앨 수 있습니다. 또한, 리터럴 타입의 추론 범위가 리터럴 값 자체로 한정되면서 좀 더 안전하게 코드를 작성할 수 있습니다.

enum vs const enum

객체 리터럴에 as const를 사용하면 enum과 매우 유사하게 사용할 수 있지만, 상수들을 하나로 묶어 추상화시킬 목적이라면, 애초에 이를 목적으로 도입된 enum을 사용하는 것이 더 나아보입니다.

사실 enumas const가 아닌 const enum와 비교해야 합니다. 둘 다 상수의 추상화라는 목적은 같지만, 트랜스파일된 결과는 아예 다르기 때문에 상황에 따라 어떤 것을 사용할 지 판단할 필요가 있습니다.

enum

// transpile 이전
enum COLOR {
  red,
  blue,
  green
}

// transpile 이후
var COLOR;
(function (COLOR) {
    COLOR[COLOR["red"] = 0] = "red";
    COLOR[COLOR["blue"] = 1] = "blue";
    COLOR[COLOR["green"] = 2] = "green";
})(COLOR || (COLOR = {}));

트랜스파일 이후 코드를 보면 한 눈에 파악하기 힘들어보이지만, 사실은

var COLOR = {
  red: 0,
  blue: 1,
  green: 2,
  '0': 'red',
  '1': 'blue',
  '2': 'green'
}

와 같은 형태의 객체로 바뀌는 것입니다. 즉, 각 프로퍼티에 특정 값들이 매핑됨과 동시에 역방향으로도 매핑이 됩니다. 이를 reverse mapping라 표현합니다.

const enum

// transpile 이전
const enum COLOR {
  red,
  blue,
  green,
}
console.log(COLOR.red);
console.log(COLOR.blue);
console.log(COLOR.green);

// transplie 이후
console.log(0 /* red */);
console.log(1 /* blue */);
console.log(2 /* green */);

트랜스파일 이후 코드를 보면, 어떠한 객체 리터럴도 존재하지 않습니다. Typescript는 const enum의 멤버에 접근하는 코드를 각 멤버의 값으로 치환하지만, enum과 달리 선언된 const enum 객체를 최종 결과물에 포함시키지 않습니다.

Reverse mapping이 필요한 경우가 아니라면, enum은 지양하고 const enum을 사용하자!

enum의 reverse mapping은 유용한 경우도 있지만, 대부분의 경우 쓸모가 없습니다. 만약 Color.red가 아니고 Color[0]라 작성할 경우, 의도한 색이 무엇인지 코드만 보고 파악할 수 없기 때문에 절대로 Color[0] 형태로 작성하지 않습니다.

이 말은 즉슨, 트랜스파일의 결과로 생성된 코드에 불필요한 코드가 추가된다는 뜻입니다. 그에 반해 const enum는 객체 리터럴 조차 결과에 남지 않기 때문에 훨씬 더 적은 코드를 만듭니다. 실제 배포되는 코드의 크기는 티끌만큼이라도 줄이는 것이 도움되기 때문에 reverse mapping이 필요한 경우가 아니라면 const enum을 사용하는 것이 좋습니다.

그렇다면 reverse mapping이 필요한 경우란?

enum LogLevel {
  ERROR,
  WARN,
  INFO,
  DEBUG,
}

function logImportant(level: LogLevel, message: string): void {
  if (level > LogLevel.WARN) return;
  const logLevel = LogLevel[level];
  console.log(`[${logLevel}] ${message}`);
}

// logImportant(0, "This is a error level message.");
logImportant(LogLevel.ERROR, "This is a error level message.");
// logImportant(1, "This is a warn level message.");
logImportant(LogLevel.WARN, "This is a warn level message.");
// logImportant(2, "This is a info level message.");
logImportant(LogLevel.INFO, "This is a info level message.");
// logImportant(3, "This is a debug level message.");
logImportant(LogLevel.DEBUG, "This is a debug level message.");

// 출력
// [ERROR] This is a error level message.
// [WARN] This is a warn level message.

참고

https://thoughtbot.com/blog/the-trouble-with-typescript-enums

2개의 댓글

comment-user-thumbnail
2022년 5월 7일

이해하기 쉬운 설명 감사합니다 :)

답글 달기
comment-user-thumbnail
2022년 5월 19일

이펙티브 타입스크립트 책 읽다가 화날뻔 했는데 덕분에 뻥 뚫리고 갑니다.. 좋은 설명 감사합니다.

답글 달기