API 응답 값을 config 객체에 매핑하는 코드를 짰다. 타입 에러 없이 빌드도 잘 됐다. 그런데 QA에서 "결제 수단 선택이 안 된다"는 티켓이 올라왔다.
원인은 허무했다. paymentMethod를 "card" | "bank" | "phone" 세 가지로 제한해 둔 함수에, config 객체에서 꺼낸 값을 넘기고 있었는데 TypeScript가 그 값을 string으로 추론하고 있었다. 타입 체크를 통과한 건 내가 중간에 as string을 한 줄 넣었기 때문이다. 지금 생각하면 범인이 바로 나였던 건데, 당시에는 "왜 string이 안 들어가지?"라는 의문에 그냥 타입 단언을 때려버린 거다.
이 문제의 근본 원인은 TypeScript의 type widening이다. 그리고 해결책은 as const 두 단어다.
Type Widening이 뭔데
TypeScript는 기본적으로 변수의 타입을 "넓게" 잡는다.
const status = "active";
// status의 타입: "active" (리터럴 타입)
let status2 = "active";
// status2의 타입: string (넓어짐!)
const로 선언하면 재할당이 불가능하니까 리터럴 타입으로 잡아준다. 여기까진 직관적이다. 문제는 객체다.
const config = {
theme: "dark",
language: "ko",
maxRetry: 3,
};
// typeof config = { theme: string; language: string; maxRetry: number }
분명 const로 선언했는데 안의 프로퍼티는 전부 string, number로 넓어진다. const는 변수의 재할당을 막을 뿐, 객체 내부 프로퍼티의 변경은 막지 않기 때문이다. config.theme = "light"가 가능하니까 TypeScript 입장에서는 "dark"가 아니라 string으로 잡는 게 맞다.
이게 왜 문제가 되냐면, 이런 코드에서 터진다.
function applyTheme(theme: "dark" | "light" | "system") {
document.documentElement.dataset.theme = theme;
}
const config = {
theme: "dark",
language: "ko",
};
applyTheme(config.theme);
// Error: Argument of type 'string' is not assignable
// to parameter of type '"dark" | "light" | "system"'
실제로는 "dark"만 들어가는데 TypeScript는 string으로 보고 있으니 타입 에러가 난다.
as const가 바꾸는 것
as const를 붙이면 객체 전체가 readonly가 되면서, 모든 프로퍼티가 리터럴 타입으로 좁아진다.
const config = {
theme: "dark",
language: "ko",
maxRetry: 3,
} as const;
// typeof config = {
// readonly theme: "dark";
// readonly language: "ko";
// readonly maxRetry: 3;
// }
config.theme이 이제 string이 아니라 "dark"다. 아까 터졌던 applyTheme(config.theme)이 에러 없이 통과한다.
여기서 주목할 건 readonly다. as const는 단순히 타입만 좁히는 게 아니라, 객체를 완전히 읽기 전용으로 만든다. config.theme = "light"를 시도하면 컴파일 에러가 난다. 런타임에서 실수로 config 값을 바꿔버리는 사고도 막아주는 거다.
배열에서 as const
배열에서 as const의 효과는 더 극적이다.
const roles = ["admin", "editor", "viewer"];
// type: string[]
const roles = ["admin", "editor", "viewer"] as const;
// type: readonly ["admin", "editor", "viewer"]
첫 번째는 string[]. 아무 문자열이나 들어갈 수 있는 배열이다. 두 번째는 readonly 튜플이면서 각 원소가 리터럴 타입이다.
이걸로 뭘 할 수 있냐면, 배열에서 유니온 타입을 뽑아낼 수 있다.
const PAYMENT_METHODS = ["card", "bank", "phone"] as const;
type PaymentMethod = (typeof PAYMENT_METHODS)[number];
// type PaymentMethod = "card" | "bank" | "phone"
이전에는 이런 식으로 따로 관리해야 했다.
type PaymentMethod = "card" | "bank" | "phone";
const PAYMENT_METHODS: PaymentMethod[] = ["card", "bank", "phone"];문제가 뭐냐면, PaymentMethod 타입에 "kakao"를 추가하면서 배열에는 안 넣는 실수가 생긴다. 혹은 그 반대도. 두 곳을 동기화해야 하는 건 언젠가 반드시 어긋난다. as const로 배열 하나를 source of truth로 만들면 이 문제가 사라진다.
내가 실제로 이 패턴을 쓰게 된 건 어드민 대시보드의 필터 드롭다운을 만들 때였다. 주문 상태 필터가 있었는데, 기획에서 상태값이 계속 추가됐다. "배송중" 추가, "교환요청" 추가... 매번 타입 정의와 상태 배열 두 군데를 고쳐야 했고, 한쪽을 빼먹어서 드롭다운에 선택지가 안 뜨는 버그가 두 번이나 났다. as const 패턴으로 바꾼 뒤로는 배열 하나만 고치면 타입이 자동으로 따라오니까 그런 일이 없어졌다.
satisfies + as const 콤보
TypeScript 4.9에서 satisfies가 나왔다. 이걸 as const와 같이 쓰면 강력해진다.
as const의 한 가지 약점은 타입 체크를 포기한다는 거다. 오타를 내도 모른다.
const config = {
theme: "drak", // 오타! dark가 아님
language: "ko",
} as const;
// 에러 없음. "drak"이라는 리터럴 타입이 될 뿐.
satisfies를 붙이면 타입 체크와 리터럴 타입 보존을 동시에 할 수 있다.
type AppConfig = {
theme: "dark" | "light" | "system";
language: "ko" | "en" | "ja";
};
const config = {
theme: "drak",
language: "ko",
} as const satisfies AppConfig;
// Error: Type '"drak"' is not assignable to type '"dark" | "light" | "system"'
오타를 잡아주면서도, 타입은 여전히 리터럴로 유지된다.
const config = {
theme: "dark",
language: "ko",
} as const satisfies AppConfig;
// config.theme의 타입: "dark" (string이 아님!)
만약 satisfies만 쓰고 as const를 안 쓰면?
const config = {
theme: "dark",
language: "ko",
} satisfies AppConfig;
// config.theme의 타입: "dark" | "light" | "system"
타입 체크는 통과하지만 theme이 유니온 타입으로 넓어진다. as const satisfies를 같이 써야 "체크도 하고, 좁히기도 하는" 최적의 결과가 나온다.
이 콤보가 빛나는 실전 사례를 하나 보자. 라우트 설정이다.
type Route = {
path: string;
label: string;
requireAuth: boolean;
};
const ROUTES = {
home: { path: "/", label: "홈", requireAuth: false },
dashboard: { path: "/dashboard", label: "대시보드", requireAuth: true },
settings: { path: "/settings", label: "설정", requireAuth: true },
} as const satisfies Record<string, Route>;
Record<string, Route>로 각 라우트 객체의 구조를 강제하면서, ROUTES.dashboard.path의 타입은 "/dashboard"라는 리터럴로 남는다. 나중에 path를 기반으로 뭔가 분기할 때 타입이 정확하게 좁아지니까 실수할 여지가 줄어든다.
const Type Parameter (TS 5.0)
TypeScript 5.0에서 추가된 const type parameter는 제네릭 함수에서 as const와 같은 효과를 주는 기능이다.
먼저 문제 상황을 보자.
function createRoute<T extends string>(path: T) {
return { path };
}
const route = createRoute("/dashboard");
// route.path의 타입: string? "/dashboard"?
다행히 이 경우는 TypeScript가 "/dashboard"로 추론한다. 하지만 객체를 넘기면 이야기가 달라진다.
function defineConfig<T extends Record<string, unknown>>(config: T) {
return config;
}
const config = defineConfig({
theme: "dark",
maxRetry: 3,
});
// config.theme의 타입: string (넓어짐)
호출하는 쪽에서 as const를 붙일 수도 있지만, 매번 사용하는 사람이 기억해야 한다.
const config = defineConfig({
theme: "dark",
maxRetry: 3,
} as const);
// 이러면 되긴 하는데, 까먹으면 끝
const type parameter를 쓰면 함수 선언 쪽에서 강제할 수 있다.
function defineConfig<const T extends Record<string, unknown>>(config: T) {
return config;
}
const config = defineConfig({
theme: "dark",
maxRetry: 3,
});
// config.theme의 타입: "dark"
// config.maxRetry의 타입: 3
<const T>에 주목하자. 제네릭 파라미터 앞에 const를 붙이면, 이 함수에 전달되는 인자가 자동으로 as const 처리된다. 사용하는 쪽에서 아무것도 안 해도 리터럴 타입이 보존된다.
라이브러리를 만드는 입장에서 이건 큰 차이다. 내가 만든 유틸 함수를 동료가 쓸 때, "이거 as const 붙여야 해요"라고 말할 필요가 없다. 함수 시그니처가 알아서 처리해준다.
실제로 이 패턴은 Zod 같은 스키마 라이브러리나 tRPC의 라우터 정의에서 내부적으로 쓰이고 있다. 사용자가 정의한 스키마 값을 그대로 타입으로 끌어올려야 하니까.
실전 패턴: 이벤트 맵
내가 가장 자주 쓰는 패턴 하나를 공유한다. 프론트엔드에서 분석 이벤트를 트래킹할 때다.
const ANALYTICS_EVENTS = {
page_view: {
category: "navigation",
requiredParams: ["page_name", "referrer"],
},
button_click: {
category: "interaction",
requiredParams: ["button_id", "button_text"],
},
purchase_complete: {
category: "conversion",
requiredParams: ["order_id", "amount", "currency"],
},
} as const satisfies Record<
string,
{ category: string; requiredParams: readonly string[] }
>;
type EventName = keyof typeof ANALYTICS_EVENTS;
// "page_view" | "button_click" | "purchase_complete"
type EventParams<T extends EventName> =
(typeof ANALYTICS_EVENTS)[T]["requiredParams"][number];
// EventParams<"purchase_complete"> = "order_id" | "amount" | "currency"
function trackEvent<T extends EventName>(
event: T,
params: Record<EventParams<T>, string>
) {
// 전송 로직
}
trackEvent("purchase_complete", {
order_id: "123",
amount: "50000",
currency: "KRW",
}); // OK
trackEvent("purchase_complete", {
order_id: "123",
}); // Error: amount, currency 빠짐
trackEvent("puchase_complete", {
// Error: 오타. "purchase_complete"여야 함
order_id: "123",
amount: "50000",
currency: "KRW",
});
이벤트 이름도 자동완성이 되고, 각 이벤트에 필요한 파라미터도 타입으로 강제된다. 이전에는 이벤트 이름을 문자열로 넘기고 파라미터는 Record<string, string>으로 받았는데, 오타가 나도 모르고 필수 파라미터를 빼먹어도 모르는 상태였다. Amplitude 대시보드에서 "왜 이벤트가 안 잡히지?" 하고 삽질한 적이 여러 번 있었다.
이 패턴으로 바꾼 뒤 트래킹 관련 버그가 확실히 줄었다. 코드 리뷰에서 "이 이벤트 파라미터 맞아?"를 확인하는 시간도 없어졌고.
주의할 점
as const가 만능은 아니다.
동적으로 값이 바뀌는 객체에는 쓸 수 없다. readonly니까 프로퍼티 재할당이 컴파일 에러가 난다. React의 useState로 관리하는 상태 같은 건 당연히 대상이 아니다.
깊게 중첩된 객체에 쓰면 타입이 복잡해진다. IDE에서 hover했을 때 타입 표시가 몇 줄씩 나오면서 오히려 가독성이 떨어질 수 있다. 나는 보통 2단계 이상 중첩되면 별도 타입을 정의하고 satisfies로 연결하는 식으로 균형을 맞춘다.
그리고 as const로 만든 객체를 함수 인자로 넘길 때, 해당 함수가 readonly 타입을 받도록 되어 있어야 한다.
const items = ["a", "b", "c"] as const;
function process(arr: string[]) {
arr.push("d");
}
process(items);
// Error: readonly ["a", "b", "c"]는 string[]에 할당 불가
readonly string[]으로 받으면 해결된다. 이건 라이브러리 코드 짤 때 특히 신경 써야 하는 부분이다.
언제 쓸까
내 기준은 단순하다. "이 값이 런타임에 변하면 안 되는 설정값인가?" 그렇다면 as const. 라우트 정의, 이벤트 이름, HTTP 메서드, 에러 코드, 권한 목록 같은 것들. 코드에서 하드코딩된 상수 역할을 하는 값이면 as const를 붙여라.
한 번 습관이 되면 자연스럽게 손이 간다. 그리고 어느 순간 string으로 추론되는 걸 보면 불안해지기 시작한다. 그게 TypeScript를 제대로 쓰기 시작했다는 신호다.
