Javascript

TypeScript에서 읽기 전용 속성 완벽 정리: readonly 키워드 활용법

드리프트2 2025. 3. 3. 16:59

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>
>>;

ReadonlySetReadonlyMap

 

ReadonlyArrayArray의 읽기 전용 버전인 것처럼, SetMap에도 읽기 전용 버전이 있습니다:

// 여기에 포함되지 않음: 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 단언은 값의 타입에만 영향을 미치는 어노테이션입니다.

다음에 적용할 수 있습니다:

  • 열거형 멤버 참조
  • 불리언 리터럴
  • 문자열 리터럴
  • 객체 리터럴
  • 배열 리터럴

두 가지 효과가 있습니다:

  1. 원시 타입이 아닌 값은 읽기 전용이 됩니다:
    • 객체: 모든 속성이 읽기 전용이 됨
    • 배열: 읽기 전용 튜플이 됨
  2. 추론된 타입이 더 좁아집니다 – 예: 'abc' (not string)

객체에 적용하면 다음과 같이 됩니다 – 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'
>>;

 

letconst로 바꾸는 것도 타입을 좁힙니다:

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 {}