들어가며.
업무를 하다 보면, 종종 상수를 다루어야 하는 경우가 생깁니다. typescript를 사용하는 경우, as const를 이용하여 상수를 표현하고는 하는데요. 이는 강제된 규칙이 아니기 때문에, as const 없이 상수를 다루는 경우도 있습니다. 단순히 상수명을 대문자로만 구성하고, 대문자로만 구성된 건 상수를 뜻한다고 코드 컨벤션을 정의할 수 있기 때문이죠. 그런데, 정말로 대문자로만 상수를 표현하는 것으로 충분할까요? 저는 상수를 다룰 때 typescript를 사용한다면, as const를 사용하기를 권장합니다. 왜 권장하냐고요? 그건.. 아래에서 자세히 알아보도록 하죠.
as const가 뭔가요?
as const는 TypeScript에서 값을 '깊은 수준의 읽기 전용(deeply readonly)'으로 표시하는 타입 어노테이션입니다. 이는 해당 값이 어떤 방식으로도 변경될 수 없음을 의미합니다. 일반적으로 as const는 객체 혹은 배열에 활용이 가능합니다. 그러나 사실 원시값에도 사용은 가능합니다.
let noName = 'haha';
let myName = 'Alice' as const;
const realName = 'Sunny';

위에서 볼 수 있듯, 보통 const realName = 'Sunny' 이런 식으로 사용하면 굳이 as const를 사용하지 않더라도 타입 추론이 잘 됩니다. 그래서 보통은 자연스레 타입이 확장되는 객체나 배열의 경우에 as const를 사용하게 됩니다.
as const의 활용 방법
위에서 설명했듯, 우리는 보통 as const를 객체 혹은 배열에 활용합니다.
객체에서의 활용
as const는 객체의 속성을 모두 readonly로 만들어 변경할 수 없도록 합니다.
const obj = {
name: "Alice",
age: 25
} as const;
// 위 코드는 아래와 같이 변환됩니다.
const obj: {
readonly name: "Alice";
readonly age: 25;
}
즉, as const의 유무는 다음과 같은 차이를 보입니다.
const obj = { name: "Alice", age: 25 };
const objWithAsConst = { name: "Alice", age: 25 } as const;
obj.name = "Bob"; // ✅ 가능 (name: string이므로 변경 가능)
objWithAsConst.name = "Bob"; // ❌ 오류 (name: "Alice"로 고정되어 변경 불가!)
배열에서의 활용
배열에 as const를 적용하면, 요소들의 타입이 더 구체적인 리터럴 타입으로 변환됩니다.
const colors = ["red", "green", "blue"] as const;
// 위 코드에서 colors의 타입은 다음과 같이 됩니다.
const colors: readonly ["red", "green", "blue"];
// 위 배열은 튜플(tuple) 타입이 되어, 요소의 위치와 값이 고정됩니다.
colors[0] = "yellow"; // ❌ 오류: 읽기 전용 속성이므로 변경할 수 없음
colors.push("yellow"); // ❌ 오류: push 메서드 사용 불가 (readonly 튜플이므로)
as const의 등장 배경
as const가 typescript 컴파일러에 의해 처리되면 readonly가 추가된 형태로 변경이 됩니다. 그러면, 이미 readonly가 있었는데 as const는 왜 등장하게 된 것일까? 하는 의문을 가져볼 수 있습니다. readonly가 있었음에도, as const가 등장한 이유를 간단히 설명하면, as const는 리터럴 타입의 추론을 개선하고, 타입 확장을 방지하기 위해 도입되었습니다.
그런데, 뭔가 찜찜합니다.
왜 타입 확장이 문제가 되었을까요? 리터럴 타입의 추론이 as const가 없었을 때에는 어떤 문제 혹은 한계를 가지고 있었을까요? 저는 as const가 과연 어떤 문제를 어떻게 해결하고자 했는지 그 과정이 궁금해졌습니다.
as const 기능이 merge 된 PR을 보면, 여러 깃헙 issue들을 토대로 아이디어를 얻어 기능을 만들게 되었다고 설명합니다. 과연 어떤 문제가 제기되었고, 문제를 제기했던 개발자들의 해결 방안은 어떤 것들이 있었을까요? PR 본문에 소개된 3가지 이슈를 한 번 소개해볼까 합니다.
1. 리터럴 타입 추론을 위한 문법 제안 (Issue #10195)
2016년 8월, 한 개발자는 리터럴 타입을 자연스럽게 사용할 수 있는 새로운 문법의 필요성을 제기했습니다. 리터럴 값을 할당해도 타입이 리터럴로 추론되지 않고, 명시적 타입 어노테이션 없이는 리터럴 타입을 얻을 방법이 없다는 것입니다. 특히 객체 리터럴이나 배열 리터럴에서는 이런 확장 문제가 두드러졌습니다. 간단한 예시를 들어보겠습니다. const arr = ['a', 1]은 기대와 달리 [string, number] 튜플이 아닌 (string | number)[]로 추론이 되는 문제가 있었습니다.
해당 이슈에서 제시한 방법은 기존 문법을 활용해 새로운 의미를 부여하는 것이었는데, 값이나 리터럴을 괄호로 감싸는 방법이었습니다. 예를 들어 const value = (true)처럼 쓰면 타입을 true로 추론하고, 그냥 true만 쓰면 기존처럼 boolean으로 추론하게 하자는 아이디어였습니다. 그러나 이 방법은 개발자가 발견하기 어려운 문법이고, 자바스크립트 생성 코드나 삼항 연산 등과 엮일 때 미묘한 충돌 가능성이 있다는 이유로 거절되었습니다.
2. 객체 리터럴에서 타입 확장 방지 방안 제안 (Issue #20195)
2017년 11월, 또 다른 개발자는 객체 리터럴에서 프로퍼티의 타입이 자동으로 확장(widening)되어, 의도와 달리 넓은 타입으로 추론되는 것에 문제제기를 했습니다. 예를 들어, const position = { x: 3 }를 선언하면 타입이 { x: number }로 추론되는데, 작성자는 x가 바뀔 일이 없으니 { x: 3 }으로 추론되길 원했습니다. 기본 변수 선언에서 const를 쓰면 primitive 값은 좁은 리터럴 타입으로 추론되지만, 객체 리터럴 내부의 값들은 여전히 확장되는 것이 문제였습니다.
해당 이슈에서는 여러 방법이 제안되었는데요, 첫 번째로 컴파일러 플래그로 “객체 리터럴 확장 추론을 안 함”이라는 모드를 추가하는 방안을 언급했습니다. 그러나 TS 팀에서는 플래그 남발은 좋지 않다며 반대의 입장을 취했습니다.
그래서 나온 두 번째 방안은 readonly 수식어를 객체 리터럴 문법에 도입하는 것이었습니다. const o = { readonly x: 3 };처럼 쓰면 컴파일러가 해당 프로퍼티를 리터럴로 취급하도록 하자는 것입니다. 더 나아가 작성자는 객체 전체에 일괄 적용하는 문법도 제안했습니다: const o = readonly { x: 3, y: 'hello' };라고 하면 o의 타입을 { readonly x: 3; readonly y: 'hello' }로 추론시키는 것입니다. 이렇게 하면 객체 리터럴 내부 모든 프로퍼티를 불변으로 간주하여 한 번에 widening을 막을 수 있다는 것입니다. 하지만 이 방법의 단점도 거론되었습니다. readonly를 여기저기 써야 한다면 여전히 코드가 장황해지는 것이 문제였고, 애초에 readonly와 타입 확장은 별개 문제라는 의견도 있었습니다.
3. 타입 리터럴 어설션을 통한 타입 확장 방지 제안 (Issue #26979)
2018년 9월, 또 다른 개발자는 기본적으로 const 변수는 리터럴 타입을 추론하지만, 함수 호출이나 객체 속성처럼 위치에 따라는 리터럴 추론을 강제할 방법이 없다는 점을 지적합니다. 예컨대 let a = 'foo'이면 a: string으로, const b = 'foo'이면 b: 'foo'로 추론되는데, 객체 리터럴에서 모든 프로퍼티를 리터럴로 지정하려면 아래처럼 매우 번거로운 작업이 필요했기 때문입니다.
const o = {
a: 42 as 42,
b: 'foo' as 'foo',
c: 'longString' as 'longString'
};
// o의 타입: { a: 42; b: 'foo'; c: 'longString'; }
이렇게 값을 일일이 자기 자신으로 캐스팅하는 것은 “믿을 수 없을 정도로 성가시며” DRY 원칙에도 반한다고 이슈 작성자는 말합니다. 게다가 'someReallyLongPropertyValue' as 'someReallyLongPropertyValue' 같은 식으로 문자열이 길어지면 더욱 낭패가 되겠죠.
이러한 문제를 해결하기 위해 as const라는 새로운 종류의 타입 단언을 도입하자는 제안을 했습니다. const 키워드는 이미 JS 예약어이므로 타입 위치에 쓸 수 없는데, 이를 타입 단언의 타입명으로 활용하자는 것이었습니다. 예를 들어 value as const 혹은 <const> value 형식으로 쓰면, 해당 표현식을 “상수 컨텍스트”로 취급하여 타입스크립트의 추론 모드를 바꾸자는 것이었습니다. 이 바뀐 모드에서는 “기존의 넓은 타입 대신 리터럴 타입을 선호하게” 된다고 설명하고 있습니다.
구체적인 동작 방식에 대해서도 언급을 했는데, Shallow literal assertion, Tuple-friendly literal assertion, Deep literal assertion 방식에 대해 언급했습니다. (자세한 내용은 해당 이슈를 참고해 주세요.)
https://github.com/microsoft/TypeScript/issues/26979
Type literal assertion `as const` to prevent type widening · Issue #26979 · microsoft/TypeScript
Search Terms String literal type and number literal type in an object literal. Type assertion to make the string literal or number literal also a type literal. Cast string or number to type literal...
github.com
소개드린 이슈들에서 공통된 문제를 뽑아보면, 리터럴 값을 있는 그대로 타입시스템에 반영하는 방법이 부족하다는 이야기였습니다. 특히, 객체 리터럴 프로퍼티의 타입 확장 문제가 심각했다고 본 것이죠. 이와 같은 문제를 인식한 개발자들은 기존 기능(const, readonly, 타입 단언(as) 등)을 이용하여 문제를 해결하려 시도했지만, 결국 한계를 마주하고 말았습니다.
기존 Typescript 기능(const, readonly, 타입 단언(as) 등)의 한계
const 키워드 (상수 변수 선언)
TS에서는 const로 변수를 선언하면 재할당이 불가하므로, 할당한 primitive 값에 대해서는 비교적 좁은 타입을 추론합니다. 예를 들어, const foo = 'bar'는 타입이 'bar'로 추론됩니다. 그러나 객체나 배열 리터럴의 경우 const로 변수를 선언해도 내부 프로퍼티나 요소는 그대로 넓은 타입으로 추론되었습니다. const profile = { name: "Alice" };를 해도 profile.name: string으로 추론되기 때문에, const 선언만으로는 깊숙한 리터럴까지 타입을 좁히지 못했습니다. 또한 함수 호출의 리턴 값을 리터럴로 좁히고 싶을 때 등에는 const를 직접 사용할 수 없기 때문에, 국지적으로 리터럴 추론을 지시하는 기능이 필요했습니다.
readonly 수식어 및 불변 타입
TypeScript는 클래스의 프로퍼티나 인터페이스/타입 선언에서 readonly 키워드를 지원하고, 또 Readonly <T>와 ReadonlyArray <T> 같은 유틸리티 타입도 제공하고 있습니다. 하지만 이들은 타입 수준에서 사용되는 기능으로, 값을 선언하는 시점에 자동 적용되지는 않습니다. 예를 들어 { x: 3 }라는 객체 리터럴을 바로 { readonly x: 3 } 타입으로 만들 순 없었고, const o: { readonly x: 3 } = { x: 3 };처럼 명시적으로 타입을 지정해야만 했습니다. #20195에서 제안되었듯이, 객체 리터럴 문법에서 readonly를 직접 쓰는 방법은 없었습니다. (TS 문법상 허용되지 않았습니다). 결국 모든 프로퍼티마다 타입 선언부에 readonly를 일일이 붙이는 수고를 해야 했는데, 이는 특히 프로퍼티가 많은 객체나 중첩 객체는 쉽지 않은 노가다가 필요했기 때문에 말 그대로 비현실적인 방법이었습니다. Object.freeze를 사용해 runtime 불변으로 만들고 타입을 Readonly <...>로 단언하는 패턴도 있지만, 여전히 한 번 더 타입 정보를 적어줘야 하는 건 마찬가지였습니다. 즉, readonly 키워드만으로는 “값을 리터럴 그대로 추론”했으면 좋겠다는 요구를 만족하기 어려웠습니다.
명시적 단언 타입
as const 이전 개발자들이 가장 많이 쓰던 해결책은 앞서 예시한 대로 각 리터럴 값 자체를 타입으로 단언하는 것이었습니다. 예를 들어 { status: "success" as "success" }처럼 작성하면 타입을 원하는 리터럴로 만들 수 있었죠. 하지만 이 방법 역시 엄청난 노가다였습니다. 값을 두 번 써야 하고, 값이 바뀌면 타입 부분도 바꿔야 합니다. 특히 값이 길거나 복잡하면 실수할 여지도 있습니다. 결국 타입 단언으로도 문제를 해결하기는 어려웠습니다.
as const의 도입과 as const가 해결한 문제들
위에서 설명한 여러 문제들을 해결하기 위해 TypeScript 3.4에서 도입된 기능이 바로 const 단언(assertion), 즉 as const입니다. 간단히 말해, as const는 “이 표현식을 상수로 취급해서 가장 좁은 타입으로 추론해 달라”는 개발자의 의도를 컴파일러에 전달합니다.
리터럴 타입 확실한 추론 (No Widening of Literals)
as const를 사용하면 더 이상 불필요한 widening이 일어나지 않습니다. 컴파일러는 문자열/숫자/불리언 리터럴에 대해 본래 그 리터럴 자체의 타입을 부여합니다. 예를 들어, let x = 10 as const;라고 하면 x의 타입은 10이 됩니다 (기존이라면 number였을 겁니다..) 이를 통해 더 이상 as를 이용한 타입 단언을 사용하지 않고도, 변수나 프로퍼티 하나하나를 정확한 리터럴 타입으로 사용할 수 있게 되었습니다.
이전에 as를 활용한 타입 단언의 예를 들어보겠습니다.
React의 인라인 스타일 객체를 정의할 때, 이전에는 const style = { textAlign: 'center' as 'center' };처럼 속성마다 캐스트해야 했지만, 이제 const style = { textAlign: 'center' } as const;로 간결하게 작성 가능해졌습니다. 이 객체의 타입은 { readonly textAlign: "center" }로 추론되므로 React 컴포넌트의 style prop 타입(예: 'center' | 'left' | ...)과 정확히 맞아떨어집니다. 이처럼 불필요한 중복 선언을 제거하여 코드가 훨씬 DRY 해졌고, 긴 리터럴도 한 번만 작성하면 되니 실수 위험도 줄었습니다.
객체 프로퍼티와 배열 요소의 불변성 및 튜플화
as const는 대상이 된 표현식뿐만 아니라 그 내부의 배열과 객체에도 특별 규칙을 적용합니다. 배열 리터럴은 자동으로 튜플로 간주되어 각 위치의 타입을 고정하고, readonly 튜플 타입이 부여됩니다. 예를 들어 let arr = [10, 20] as const;를 하면 arr의 타입은 readonly [10, 20]으로 추론됩니다. 기존에는 그냥 number []였겠죠. 또한 객체 리터럴에 as const를 붙이면 모든 프로퍼티가 자동으로 readonly가 되며, 각 프로퍼티 값도 리터럴이면 리터럴 타입으로 유지됩니다. let obj = { text: "hi" } as const;의 타입은 { readonly text: "hi" }가 되는 식입니다.
특히 중첩 구조에 대해서 깊이 적용된다는 점이 중요합니다. 다음 예시를 통해 얼마나 편해졌는지 알아봅시다.
let obj = { x: 10, y: [20, 30], z: { a: { b: 42 } } } as const;
// 이 객체의 타입은 아래와 같이 추론이 됩니다.
{
readonly x: 10;
readonly y: readonly [20, 30];
readonly z: { readonly a: { readonly b: 42; } };
}
as const 한 번으로 최상위 객체는 물론이고, 그 안에 있는 모든 리터럴 값들이 재귀적으로 불변 처리하게 되었습니다.
명시적 의도 전달과 타입 안정성 향상
as const를 사용한다는 것은 해당 값들이 변하지 않는 상수임을 명시하는 것입니다. TypeScript 타입 시스템은 가변성이 있을 때 타입을 넓게 보는 경향이 있는데, as const로 불변성을 알려주면 안전하게 좁은 타입을 사용할 수 있습니다. 예를 들어 const arr = [1, 2] as const;로 arr을 [1, 2] 타입으로 만들었으면, arr.push(3) 같은 가변 메서드는 허용되지 않습니다. 프로퍼티도 readonly이기 때문에 실수로 값을 바꾸는 것을 컴파일러가 잡아줍니다.
(다음과 같은 에러가 표시됩니다.)
Property 'push' does not exist on type 'readonly [1, 2]'.
결국, as const는 개발자의 의도를 코드에 반영하여, 잘못된 값 변경이나 타입 불일치를 예방하는 효과도 있습니다.
사용 가능 대상과 제한 사항
물론, as const는 만능이 아닙니다. as const는 리터럴 표현식 또는 리터럴의 조합에만 쓸 수 있는데, 무슨 이야기인가 하면, 리터럴이 아닌 임의의 표현식에 붙이면 오류를 발생시킨다는 것입니다. 예를 들어 (x + y) as const처럼 쓰면 컴파일 에러가 납니다. as const를 사용할 수 있는 형태는 숫자/불리언/문자열 리터럴, 배열/객체 리터럴, 그리고 여기에 괄호로 한번 더 묶은 것이나 배열 내 요소, 객체 내 값 자리 등입니다. 이는 설계적으로 as const가 “값을 그대로 동결”하는 용도이지 임의의 연산 결과를 바꾸는 만능 단언이 아님을 뜻합니다. (사실 생각해 보면, 연산 결과는 변수에 담아 const로 선언하면 되기 때문에 굳이...?라는 생각이 들기도 합니다.)
정리하면..
정리해 보면, as const는 “값을 그대로 타입화”함으로써 앞서 제기된 거의 모든 문제를 해결하게 되었는데요, TypeScript의 타입 추론을 한층 정밀하게 만들어 주었고, 특히 불변 데이터 패턴을 쉽게 다룰 수 있게 해 주었습니다. 이로 인해 Typescript를 사용하는 개발자들은 더욱 타입 안전하고 의도가 잘 드러나는 코드를 작성할 수 있게 되었습니다.
Typescript를 사용하고 있다면, as const... 써야 하지 않을까요?
(물론, 적절한 상황과 맥락에서만 사용해야 합니다!)
p.s. (1)
본문에 만약 잘못된 정보가 있다면 댓글로 피드백을 부탁드립니다!
p.s. (2)
실제 as const가 머지된 PR https://github.com/Microsoft/TypeScript/pull/29510에 들어가 보시면 더욱 자세하고 심오한 내용들을 보실 수 있습니다.
Const contexts for literal expressions by ahejlsberg · Pull Request #29510 · microsoft/TypeScript
With this PR we introduce const assertions which favor immutability in the types inferred for literal expressions (inspired by suggestions in #10195, #20195, and #26979). A const assertion is simpl...
github.com
'About IT > 웹 개발' 카테고리의 다른 글
Biome에 대해 아세요? (0) | 2025.02.28 |
---|---|
익숙한 듯 익숙하지 않은 쿠키(Cookie)의 보안에 대해 알아보자 (0) | 2025.02.03 |
JS로 DOM 꾸미기: 스타일 적용의 6가지 방법 (1) | 2025.01.19 |
알고 쓰는 서브 픽셀: 웹 개발자와 디자이너의 협업을 위한 필수 지식 (1) | 2024.12.22 |
Next.js 배포 실패? Vercel에서 Sentry CLI 오류 해결하기 (0) | 2024.11.24 |