TypeScript에서 읽기 전용 속성 완벽 정리: readonly 키워드 활용법
안녕하세요, 여러분!
오늘은 TypeScript에서 "읽기 전용" 기능을 어떻게 활용할 수 있는지 알아볼까요?
주로 readonly
키워드를 중심으로 다뤄볼 건데요.
이 글에서 사용할 표기법
소스 코드에서 계산된 타입이나 추론된 타입을 보여주기 위해 asserttt
라는 npm 패키지를 사용합니다.
예를 들어:
// 값의 타입
assertType<string>('abc');
assertType<number>(123);
// 타입의 동등성
type Pair<T> = [T, T];
type _ = Assert<Equal<
Pair<string>, [string,string]
>>;
const
변수 선언: 바인딩만 불변
JavaScript에서는 const
로 변수를 선언하면 바인딩은 불변이지만, 바인딩된 값 자체는 변경 가능합니다:
const obj = {prop: 'yes'};
// 다른 값을 할당할 수는 없습니다:
assert.throws(
() => obj = {},
/^TypeError: Assignment to constant variable./
);
// 하지만 할당된 값을 수정하는 건 가능합니다:
obj.prop = 'no'; // OK
TypeScript의 읽기 전용 기능도 비슷한데요.
다만, 이건 컴파일 타임에서만 검사되고, 생성된 JavaScript 코드에는 영향을 주지 않습니다.
읽기 전용 객체 속성
readonly
키워드를 사용하면 객체 속성을 불변으로 만들 수 있습니다:
type ReadonlyProp = {
readonly prop: { str: string },
};
const obj: ReadonlyProp = {
prop: { str: 'a' },
};
속성을 불변으로 만들면 다음과 같은 결과가 생깁니다:
// 속성은 불변입니다:
// @ts-expect-error: Cannot assign to 'prop' because it is
// a read-only property.
obj.prop = { str: 'x' };
// 하지만 속성 값은 변경 가능합니다:
obj.prop.str += 'b';
초기화 이후 변경 불가
만약 .count
속성이 읽기 전용이라면, 객체 리터럴을 통해 초기화할 수는 있지만 이후에는 변경할 수 없습니다.
값을 변경하려면 새 객체를 만들어야 합니다 (A 라인):
type Counter = {
readonly count: number,
};
function createCounter(): Counter {
return { count: 0 };
}
function toIncremented(counter: Counter): Counter {
return { // (A)
count: counter.count + 1,
};
}
아래처럼 값을 변경하려는 코드는 컴파일 타임 에러를 발생시킵니다:
function increment(counter: Counter): void {
// @ts-expect-error: Cannot assign to 'count' because it is
// a read-only property.
counter.count++;
}
readonly
는 할당 가능성에 영향을 주지 않습니다
놀랍게도, readonly
는 할당 가능성에는 영향을 주지 않습니다:
type Obj = { prop: number };
type ReadonlyObj = { readonly prop: number };
function func(_obj: Obj) { }
function readonlyFunc(_readonlyObj: ReadonlyObj) { }
const obj: Obj = { prop: 123 };
func(obj);
readonlyFunc(obj);
const readonlyObj: ReadonlyObj = { prop: 123 };
func(readonlyObj);
readonlyFunc(readonlyObj);
하지만 타입 동등성을 통해 이를 감지할 수 있습니다:
type _ = Assert<Not<Equal<
{ readonly prop: number },
{ prop: number }
>>>;
컴파일러 옵션 --enforceReadonly
를 추가하는 풀 리퀘스트가 이미 제출된 상태입니다.
읽기 전용 인덱스 시그니처
속성뿐만 아니라 인덱스 시그니처도 readonly
로 수정할 수 있습니다.
아래는 Array와 유사한 객체를 설명하는 내장 타입입니다:
interface ArrayLike<T> {
readonly length: number;
readonly [n: number]: T; // (A)
}
A 라인은 읽기 전용 인덱스 시그니처입니다.
ArrayLike
가 사용되는 예시로는 Array.from()
의 파라미터 타입이 있습니다:
interface ArrayConstructor {
from<T>(iterable: Iterable<T> | ArrayLike<T>): T[];
// ···
}
인덱스 시그니처가 읽기 전용이면, 인덱스 접근을 통해 값을 변경할 수 없습니다:
const arrayLike: ArrayLike<string> = {
length: 2,
0: 'a',
1: 'b',
};
assert.deepEqual(
Array.from(arrayLike), ['a', 'b']
);
assert.equal(
// 읽기는 허용됩니다:
arrayLike[0], 'a'
);
// 쓰기는 허용되지 않습니다:
// @ts-expect-error: Index signature in type 'ArrayLike<string>'
// only permits reading.
arrayLike[0] = 'x';
유틸리티 타입 Readonly<T>
Readonly<T>
유틸리티 타입은 T
의 모든 속성을 읽기 전용으로 만듭니다:
type Point = {
x: number,
y: number,
dist(): number,
};
type ReadonlyPoint = Readonly<Point>;
type _ = Assert<Equal<
ReadonlyPoint,
{
readonly x: number,
readonly y: number,
readonly dist: () => number,
}
>>;
클래스 속성
클래스에서도 읽기 전용 속성을 사용할 수 있습니다.
이런 속성은 직접 초기화하거나 생성자에서 초기화해야 하며, 이후에는 변경할 수 없습니다.
그래서 아래처럼 변경하려는 incMut()
메서드는 동작하지 않습니다:
class Counter {
readonly count: number;
constructor(count: number) {
this.count = count;
}
inc(): Counter {
return new Counter(this.count + 1);
}
incMut(): void {
// @ts-expect-error: Cannot assign to 'count' because
// it is a read-only property.
this.count++;
}
}
읽기 전용 배열
문자열 배열을 읽기 전용으로 선언하는 방법은 두 가지가 있습니다:
ReadonlyArray<string>
readonly string[]
ReadonlyArray
는 타입일 뿐이며, 런타임에는 존재하지 않습니다.
사용 예시는 다음과 같습니다:
const arr: ReadonlyArray<string> = ['a', 'b'];
// @ts-expect-error: Index signature in type 'readonly string[]'
// only permits reading.
arr[0] = 'x';
// @ts-expect-error: Cannot assign to 'length' because it is
// a read-only property.
arr.length = 1;
// @ts-expect-error: Property 'push' does not exist on
// type 'readonly string[]'.
arr.push('x');
일반 배열을 만들고 ReadonlyArray<string>
타입을 부여하는 방식으로 타입 레벨에서 읽기 전용 배열을 만들 수 있습니다.
마지막 줄에서 볼 수 있듯, ReadonlyArray
타입은 속성과 인덱스 시그니처를 읽기 전용으로 만들 뿐만 아니라 변경 메서드도 제외합니다:
interface ReadonlyArray<T> {
readonly length: number;
readonly [n: number]: T;
// 포함: .map(), .filter() 등 비파괴적 메서드
// 제외: .push(), .sort() 등 파괴적 메서드
// ···
}
런타임에서 읽기 전용 배열을 만들고 싶다면, 아래와 같은 접근법을 사용할 수 있습니다:
class ImmutableArray<T> {
#arr: Array<T>;
constructor(arr: Array<T>) {
this.#arr = arr;
}
get length(): number {
return this.#arr.length;
}
at(index: number): T | undefined {
return this.#arr.at(index);
}
map<U>(
callbackfn: (value: T, index: number, array: readonly T[]) => U,
thisArg?: any
): U[] {
return this.#arr.map(callbackfn, thisArg);
}
// (많은 메서드 생략)
}
ReadonlyArray<T>
를 구현하지 않은 이유는 대괄호를 통한 인덱스 접근을 제공하지 않고, .at()
만 제공하기 때문입니다.
대괄호 접근은 Proxy를 통해 구현할 수 있지만, 코드가 덜 우아하고 성능도 떨어집니다.
읽기 전용 튜플
일반 튜플에서는 요소에 다른 값을 할당할 수 있지만, 길이는 변경할 수 없습니다:
const tuple: [string, number] = ['a', 1];
tuple[0] = 'x'; // OK
tuple.length = 2; // OK
// @ts-expect-error: Type '1' is not assignable to type '2'.
tuple.length = 1;
// `.length`의 타입은 2입니다 (`number`가 아님)
type _ = Assert<Equal<
(typeof tuple)['length'], 2
>>;
// 흥미롭게도, `.push()`는 허용됩니다:
tuple.push('x'); // OK
튜플이 읽기 전용이면, 요소나 .length
모두 다른 값을 할당할 수 없습니다:
const tuple: readonly [string, number] = ['a', 1];
// @ts-expect-error: Cannot assign to '0' because it is
// a read-only property.
tuple[0] = 'x';
// @ts-expect-error: Cannot assign to 'length' because it is
// a read-only property.
tuple.length = 2;
// @ts-expect-error: Property 'push' does not exist on
// type 'readonly [string, number]'.
tuple.push('x');
읽기 전용 튜플은 처음 설정한 후 변경할 수 없습니다.
읽기 전용 튜플의 타입은 ReadonlyArray
의 서브타입입니다:
type _ = Assert<Extends<
typeof tuple, ReadonlyArray<string | number>
>>;
ReadonlySet
과 ReadonlyMap
ReadonlyArray
가 Array
의 읽기 전용 버전인 것처럼, Set
과 Map
에도 읽기 전용 버전이 있습니다:
// 여기에 포함되지 않음: lib.es2015.iterable.d.ts에 정의된 메서드
// 예: .keys(), .[Symbol.iterator]()
interface ReadonlySet<T> {
forEach(
callbackfn: (value: T, value2: T, set: ReadonlySet<T>) => void,
thisArg?: any
): void;
has(value: T): boolean;
readonly size: number;
}
interface ReadonlyMap<K, V> {
forEach(
callbackfn: (value: V, key: K, map: ReadonlyMap<K, V>) => void,
thisArg?: any
): void;
get(key: K): V | undefined;
has(key: K): boolean;
readonly size: number;
}
ReadonlySet
을 사용하는 예시는 다음과 같습니다:
const COLORS: ReadonlySet<string> = new Set(['red', 'green']);
런타임에서 Set
을 읽기 전용으로 만드는 래퍼 클래스는 다음과 같습니다:
class ImmutableSet<T> implements ReadonlySet<T> {
#set: Set<T>;
constructor(set: Set<T>) {
this.#set = set;
}
get size(): number {
return this.#set.size;
}
forEach(
callbackfn: (value: T, value2: T, set: ReadonlySet<T>) => void,
thisArg?: any
): void {
return this.#set.forEach(callbackfn, thisArg);
}
has(value: T): boolean {
return this.#set.has(value);
}
entries(): SetIterator<[T, T]> {
return this.#set.entries();
}
keys(): SetIterator<T> {
return this.#set.keys();
}
values(): SetIterator<T> {
return this.#set.values();
}
[Symbol.iterator](): SetIterator<T> {
return this.#set[Symbol.iterator]();
}
}
as const
단언
as const
단언은 값의 타입에만 영향을 미치는 어노테이션입니다.
다음에 적용할 수 있습니다:
- 열거형 멤버 참조
- 불리언 리터럴
- 문자열 리터럴
- 객체 리터럴
- 배열 리터럴
두 가지 효과가 있습니다:
- 원시 타입이 아닌 값은 읽기 전용이 됩니다:
- 객체: 모든 속성이 읽기 전용이 됨
- 배열: 읽기 전용 튜플이 됨
- 추론된 타입이 더 좁아집니다 – 예:
'abc'
(notstring
)
객체에 적용하면 다음과 같이 됩니다 – readonly
와 더 좁은 타입(123 vs.
number
)을 주목하세요:
const obj = { prop: 123 };
type _1 = Assert<Equal<
typeof obj, { prop: number }
>>;
const constObj = { prop: 123 } as const;
type _2 = Assert<Equal<
typeof constObj, { readonly prop: 123 }
>>;
배열에 적용하면 다음과 같이 됩니다 – readonly
와 더 좁은 타입('a'와 'b' vs.
string
)을 주목하세요:
const arr = ['a', 'b'];
type _1 = Assert<Equal<
typeof arr, string[]
>>;
const constTuple = ['a', 'b'] as const;
type _2 = Assert<Equal<
typeof constTuple, readonly ["a", "b"]
>>;
원시 값은 이미 불변이므로, as const
는 타입을 더 좁게 추론하는 데만 영향을 미칩니다:
let str1 = 'abc';
type _1 = Assert<Equal<
typeof str1, string
>>;
let str2 = 'abc' as const;
type _2 = Assert<Equal<
typeof str2, 'abc'
>>;
let
을 const
로 바꾸는 것도 타입을 좁힙니다:
const str3 = 'abc';
type _3 = Assert<Equal<
typeof str3, 'abc'
>>;
사용 권장 사항
읽기 전용 튜플을 받아들이려면 ReadonlyArray
가 필요합니다 readonly
가 할당 가능성에 영향을 주지 않더라도, 읽기 전용 튜플은 ReadonlyArray
의 서브타입이며, 따라서 Array
와 호환되지 않습니다.
왜냐하면 후자는 전자가 가지지 않은 메서드를 가지고 있기 때문입니다.
함수와 제네릭 타입에서 이것이 무엇을 의미하는지 살펴볼까요.
함수
아래 sum()
함수는 읽기 전용 튜플에 적용할 수 없습니다:
function sum(numbers: Array<number>): number {
return numbers.reduce((acc, x) => acc + x, 0);
}
sum([1, 2, 3]); // OK
const readonlyTuple = [1, 2, 3] as const;
// @ts-expect-error: Argument of type 'readonly [1, 2, 3]'
// is not assignable to parameter of type 'number[]'.
sum(readonlyTuple);
이를 해결하려면 ReadonlyArray
를 사용해야 합니다:
function sum(numbers: ReadonlyArray<number>): number {
return numbers.reduce((acc, x) => acc + x, 0);
}
const readonlyTuple = [1, 2, 3] as const;
sum(readonlyTuple); // OK
제네릭 타입
타입 T
가 일반 배열 타입으로 제약되면, as const
리터럴의 타입과 일치하지 않습니다:
type Wrap<T extends Array<unknown>> = Promise<T>;
const arr = ['a', 'b'] as const;
// @ts-expect-error: Type 'readonly ["a", "b"]' does not satisfy
// the constraint 'unknown[]'.
type _ = Wrap<typeof arr>;
이를 해결하려면 ReadonlyArray
로 전환해야 합니다:
type Wrap<T extends ReadonlyArray<unknown>> = Promise<T>;
const arr = ['a', 'b'] as const;
type Result = Wrap<typeof arr>;
type _ = Assert<Equal<
Result, Promise<readonly ["a", "b"]>
>>;
ReadonlyArray
사용 시 주의점
문제: 대부분의 코드는 Array
를 사용합니다.
function appFunc(arr: ReadonlyArray<string>): void {
// @ts-expect-error: Argument of type 'readonly string[]'
// is not assignable to parameter of type 'string[]'.
libFunc(arr);
}
function libFunc(arr: Array<string>): void {}
'Javascript' 카테고리의 다른 글
TypeScript의 infer 키워드로 복합 타입에서 원하는 부분만 깔끔하게 추출하기 (0) | 2025.03.13 |
---|---|
TypeScript satisfies 연산자 완벽 정리: 타입 체크의 새로운 강자 (0) | 2025.03.03 |
TypeScript로 구현하는 최신 ESM 기반 npm 패키지 퍼블리싱 가이드 (0) | 2025.03.03 |
JavaScript Temporal, 날짜와 시간을 다루는 새로운 방법 (0) | 2025.03.02 |
Astro 완벽 가이드: 초보자부터 고급 사용자까지 (0) | 2025.03.01 |