TypeScript as const

as const는 값을 가장 좁은 타입으로 고정시키는 TypeScript의 assertion 키워드다. 단순히 리터럴 타입을 만드는 것뿐만 아니라 readonly 처리, 컴파일 타임과 런타임의 차이, enum과의 비교까지 엮여 있는 주제라 한 번 정리해본다.

as const가 하는 일

as const는 값을 가장 좁은 타입으로 고정시키는 assertion이다. 말로 하면 추상적이니 바로 코드로 보자.

const arr = [1, 2, 3];          // number[]
const arrConst = [1, 2, 3] as const; // readonly [1, 2, 3]

const obj = { x: 1, y: 2 };          // { x: number, y: number }
const objConst = { x: 1, y: 2 } as const; // { readonly x: 1, readonly y: 2 }

as const를 붙이면 세 가지가 바뀐다.

실제로 제일 많이 쓰는 패턴을 보자.

const COLORS = ["red", "green", "blue"] as const;
type Color = typeof COLORS[number]; // "red" | "green" | "blue"

배열 하나로 값 목록과 타입을 동시에 뽑아내는 트릭인데, 이 방식으로 유니온 타입을 커버할 수 있다.

함수 인자로 넘길 때도 차이가 난다.

function move(direction: "up" | "down") { /* ... */ }

const dir = "up";
move(dir); // ❌ dir은 string으로 추론돼서 에러

const dir2 = "up" as const;
move(dir2); // ✅ "up" 리터럴이라 통과

readonly, 정확히 뭘 막는 것일까

readonly가 붙으면 값을 바꾸는 모든 시도가 컴파일 타임에 막힌다.

const arr = [1, 2, 3] as const;

arr[0] = 10;   // ❌ 인덱스 재할당 불가
arr.push(4);   // ❌ push 자체가 타입에 없음
arr.sort();    // ❌ 원본 변경 메서드 불가

console.log(arr[0]); // ✅ 읽기는 자유

객체도 마찬가지.

const user = { name: "Kim", age: 30 } as const;

user.name = "Lee"; // ❌
delete user.name;  // ❌

여기서 은근히 많이 놓치는 부분이 있는데, as const는 한 단계만 readonly로 만든다.

const data = { info: { x: 1 } } as const;

data.info = { x: 2 }; // ❌ info 자체는 막힘
data.info.x = 2;      // ✅ 이건 그냥 통과됨

내부까지 완전히 막고 싶으면 직접 재귀 타입을 만들거나 type-fest의 ReadonlyDeep 같은 걸 써야 한다.

또 하나, readonly 배열을 뮤터블 배열을 요구하는 함수에 넘기면 타입 에러가 난다.

function addItem(arr: number[]) {
  arr.push(4);
}

const nums = [1, 2, 3] as const;
addItem(nums); // ❌ readonly [1,2,3]은 number[]에 할당 불가

이것은 컴파일 타임의 타입체크일뿐

맞다. readonly, as const는 타입 정보일 뿐이고 JS로 트랜스파일되면 그냥 사라진다.

// TS
const arr = [1, 2, 3] as const;
arr[0] = 10; // 컴파일 에러

// 컴파일된 JS
const arr = [1, 2, 3];
arr[0] = 10; // 그냥 실행됨

타입 체크를 우회하거나(@ts-ignore), 애초에 순수 JS 파일이면 이 제약은 존재하지 않는다. 진짜로 런타임에서도 값을 못 바꾸게 하고 싶다면 Object.freeze()가 필요하다.

Object.freeze() 실제 사용법

const obj = Object.freeze({ x: 1, y: 2 });

obj.x = 100;  // 무시됨 (strict mode면 TypeError)
delete obj.y; // 무시됨
obj.z = 3;    // 새 프로퍼티 추가도 무시됨

배열도 동일하게 얼어붙는다.

const arr = Object.freeze([1, 2, 3]);
arr.push(4); // TypeError — length 자체를 못 바꾸니까

주의할 점이 하나 있는데, non-strict 모드에서는 조용히 무시만 되고 에러가 안 난다. ESM은 기본이 strict mode라 이 부분에서 좀 더 안전하다.

"use strict";

const obj = Object.freeze({ x: 1 });
obj.x = 2; // TypeError: Cannot assign to read only property

freeze도 readonly와 마찬가지로 얕게만 적용된다.

const data = Object.freeze({ info: { x: 1 } });
data.info.x = 2; // 그냥 통과됨, info는 안 얼어붙음

내부까지 얼리려면 재귀 함수가 필요하다.

function deepFreeze(obj) {
  Object.getOwnPropertyNames(obj).forEach((key) => {
    const value = obj[key];
    if (value && typeof value === "object") deepFreeze(value);
  });
  return Object.freeze(obj);
}

얼려졌는지는 Object.isFrozen(obj)로 확인 가능하다.

예시 케이스

간단한 케이스에서는 표준적으로 쓰인다. 설정 객체, 상수 목록, 라이브러리 공개 API 정도.

const CONFIG = Object.freeze({
  API_URL: "https://api.example.com",
  MAX_RETRY: 3,
});

다만 상태 관리처럼 중첩 객체가 복잡해지는 상황에서는 얕은 freeze의 한계 때문에 매번 deepFreeze를 만드는 게 번거로워지고, 성능 이슈도 생긴다. 그래서 실무에서는 Immer나 Immutable.js 같은 전용 라이브러리로 넘어가는 경우가 많다.

import { produce } from "immer";

const nextState = produce(state, (draft) => {
  draft.info.x = 2; // 내부적으로 불변성 유지하면서 이렇게 씀
});

정리하면 간단한 상수 보호는 Object.freeze()로 충분하고, 복잡한 상태 관리는 전용 라이브러리를 쓰는 쪽이 낫다.

enum vs as const, 뭐가 다른가

둘 다 “고정된 값 집합”을 표현하는 용도로 쓰이지만 컴파일 결과부터 다르다.

// enum
enum Color {
  Red = "RED",
  Green = "GREEN",
}

// as const
const Color = {
  Red: "RED",
  Green: "GREEN",
} as const;

enum은 컴파일하면 실제 런타임 객체가 IIFE로 생성된다.

var Color;
(function (Color) {
  Color["Red"] = "RED";
  Color["Green"] = "GREEN";
})(Color || (Color = {}));

반면 as const 객체는 그냥 평범한 객체 리터럴로 남는다. 이 차이 때문에 번들 크기와 트리쉐이킹 측면에서 as const 쪽이 유리하다.

숫자 enum에는 역방향 매핑이라는 독특한 동작도 있다.

enum Status { Active, Inactive } // 0, 1

console.log(Status.Active); // 0
console.log(Status[0]);     // "Active"

as const 객체는 순수 객체라 이런 역방향 매핑이 없다.

타입 사용 방식에서도 차이가 나는데, enum은 문자열 리터럴을 그대로 넘길 수 없다.

enum Color { Red = "RED" }

function paint(c: Color) { /* ... */ }

paint(Color.Red); // ✅
paint("RED");     // ❌ 에러

// as const
const Color = { Red: "RED" } as const;
type Color = typeof Color[keyof typeof Color];

function paint(c: Color) { /* ... */ }

paint(Color.Red); // ✅
paint("RED");     // ✅ 리터럴 유니온이라 가능

참고로 const enum이라는 것도 있는데, 컴파일 시 완전히 인라인되어 런타임 객체 자체가 안 생긴다. 다만 isolatedModules 환경에서 제약이 있어서 라이브러리 코드에는 잘 안 쓴다.

항목enumas const
런타임 코드 생성O (IIFE)X (순수 객체)
번들 크기상대적으로 큼작음
숫자 역방향 매핑OX
문자열 리터럴 직접 대입XO
트리쉐이킹어려움쉬움

최근 TS 생태계에서는 enum보다 as const 객체 패턴이 더 권장되는 추세다. 번들 크기, JS 호환성, 트리쉐이킹 전부 유리하기 때문이다.

마무리

as const는 타입을 좁히는 컴파일 타임 도구고, Object.freeze()는 런타임 보호 도구다. 둘은 목적이 다르니 필요에 따라 같이 쓰면 된다. enum 대신 as const 객체를 쓰는 패턴이 선호되는 이유도 함께 짚어봤다.

글 목록으로