readonly는 객체를 만들 때 한 번 설정하고 다시는 대입할 수 없는 속성을 표시합니다. Readonly<T>는 이를 타입의 모든 속성에 적용하고, readonly T[]는 배열에 같은 일을 합니다.
마지막 줄은 readonly에 관한 가장 중요한 사실을 보여 줍니다. readonly는 컴파일러가 검사할 뿐 런타임에 강제되지 않습니다. 대입은 컴파일 오류였지만(여기서는 @ts-expect-error로 억제), 출력된 JavaScript는 그래도 그 대입을 실행했습니다. 억제하지 않았다면 파일이 컴파일되지 않았을 것이고, 바로 그 지점에서 readonly가 제 역할을 합니다.
readonly 속성
인터페이스, 타입 리터럴, 클래스에서 속성 이름 앞에 readonly를 붙입니다. 이 속성은 초기화할 수 있지만 다시 대입할 수는 없습니다.
클래스에서 readonly 필드는 선언부나 생성자에서만 대입할 수 있습니다. 가장 짧은 형태는 매개변수 속성 constructor(readonly id: string) {}으로, 필드 선언과 대입을 한 번에 합니다. 필드와 생성자 전반은 클래스 페이지에서 다룹니다.
Readonly<T>: 모든 속성을 한 번에
Readonly<T>는 T의 모든 속성을 readonly로 표시하는 유틸리티 타입입니다. 애플리케이션 상태처럼 여기저기 넘기지만 바꾸면 안 되는 값에 유용합니다.
함수 시그니처는 addItem이 기존 상태를 바꾸지 않고 새 상태를 반환한다는 것을 읽는 사람에게 알려 주고, 컴파일러는 함수가 그 약속을 지키게 합니다. Readonly<T>는 매핑된 타입 { readonly [P in keyof T]: T[P] }로 정의되어 있습니다.
읽기 전용 배열: readonly T[]와 ReadonlyArray<T>
readonly number[]와 ReadonlyArray<number>는 같은 타입입니다. 모든 변경 메서드(push, pop, shift, splice, sort, reverse, fill 등)를 없애고 인덱스 대입을 금지합니다. 변경하지 않는 메서드는 남아 있고 일반 배열을 반환합니다.
매개변수로 readonly T[]를 받는 것은 호출하는 쪽의 배열을 수정하지 않겠다는 약속입니다. 사람들이 막히는 것은 반대 방향입니다. 읽기 전용 배열은 일반 T[]를 받는 함수에 넘길 수 없습니다. 그 함수가 배열을 변경할 수도 있기 때문입니다.
index.ts(7,17): error TS4104: The type 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.
sum은 아무것도 변경하지 않으므로 readonly number[]를 받도록 바꾸는 것이 해결책입니다. 배열을 읽기만 하는 함수는 항상 읽기 전용 타입을 받아야 하며, 그러면 두 종류를 모두 받을 수 있습니다. 직접 소유하지 않은 함수라면 복사본을 넘기세요: sum([...prices]).
ReadonlyMap과 ReadonlySet
Map과 Set에도 읽기 전용 버전이 있습니다. ReadonlyMap<K, V>에는 get, has, size, forEach와 이터레이터가 있지만 set, delete, clear는 없고, ReadonlySet<T>에는 add, delete, clear가 없습니다.
클래스는 흔히 private으로 변경 가능한 Map을 두고 ReadonlyMap 타입의 getter로 공개합니다. 그러면 외부 코드는 그 참조로 데이터를 읽을 수만 있고 바꿀 수는 없습니다.
readonly는 얕다
readonly와 Readonly<T>는 속성 자체만 보호하며, 그 속성이 가리키는 객체나 배열은 보호하지 않습니다.
DeepReadonly<T>는 중첩된 모든 객체 타입에 자기 자신을 적용하며, 배열 타입에 대한 매핑된 타입은 읽기 전용 배열을 만들므로 members는 readonly string[]이 됩니다. 이것도 런타임 보호가 아니라 타입 수준의 약속입니다.
컴파일 타임 전용: 다른 참조를 통한 변경
읽기 전용 타입은 참조 하나가 할 수 있는 일을 제어합니다. 같은 객체를 가리키면서 readonly 없이 타입이 지정된 다른 참조는 그 객체를 바꿀 수 있고, TypeScript는 읽기 전용 타입을 변경 가능한 타입에 대입하는 것도 허용합니다.
TypeScript는 두 객체 타입이 호환되는지 검사할 때 readonly 속성을 고려하지 않으므로 mutable = settings 대입이 컴파일됩니다. TypeScript 핸드북도 이를 직접 밝히며, 그래서 readonly 속성이 별칭을 통해 바뀔 수 있다고 설명합니다. 읽기 전용 배열은 다릅니다. 위의 TS4104 오류가 바로 그 검사입니다. Object.freeze는 런타임 변경을 실제로 막습니다. 출력된 코드는 strict 모드에서 실행되며, strict 모드에서 동결된 속성에 쓰면 TypeError가 발생합니다. readonly처럼 Object.freeze도 얕습니다.
readonly, const, as const, Object.freeze
리터럴에 as const를 붙이면 모든 깊이의 속성이 readonly가 되고 리터럴 타입이 유지되므로, 깊게 읽기 전용인 값을 얻는 가장 쉬운 방법인 경우가 많습니다.
const theme = { mode: "dark", sizes: [12, 14] } as const;
// { readonly mode: "dark"; readonly sizes: readonly [12, 14] }
| 적용 대상 | 깊게 적용? | 런타임 효과 | 예시 | |
|---|---|---|---|---|
const | 변수 바인딩 | 아니요 | 변수를 다시 대입할 수 없음 | const user = {...} |
readonly | 속성 하나 또는 배열 타입 | 아니요 | 없음 | readonly id: string |
Readonly<T> | 타입의 모든 속성 | 아니요 | 없음 | Readonly<State> |
as const | 리터럴 표현식 | 예 | 없음 | { ... } as const |
Object.freeze | 객체 값 | 아니요 | 쓰기 실패(strict 모드에서 예외 발생) | Object.freeze(obj) |
const와 readonly는 서로 다른 질문에 답합니다. const는 이름이 다른 곳을 가리키지 못하게 하고, readonly는 속성이 바뀌지 못하게 합니다. const 객체의 속성도 readonly가 아니라면 여전히 다시 대입할 수 있습니다.
자주 묻는 질문
TypeScript에서 readonly는 무엇을 하나요?
readonly는 객체를 만들 때(또는 클래스 생성자에서) 설정할 수 있지만 그 뒤에는 다시 대입할 수 없는 속성을 표시합니다. 나중에 대입하면 컴파일 오류 TS2540이 납니다. 타입 검사일 뿐이며, 출력된 JavaScript에는 아무 보호 장치도 없습니다.
TypeScript에서 readonly와 const의 차이는 무엇인가요?
const는 변수에 관한 것입니다. 이름이 다른 값을 가리키게 할 수는 없지만 그 이름이 담은 객체는 여전히 바꿀 수 있습니다. readonly는 속성에 관한 것으로, 그 속성을 다시 대입할 수 없습니다. const user = { name: "Ada" }는 여전히 user.name = "x"를 허용하지만 readonly name 속성은 허용하지 않습니다.
TypeScript에서 배열을 readonly로 만들려면 어떻게 하나요?
readonly T[] 또는 ReadonlyArray<T>(같은 타입)로 타입을 지정합니다. push, pop, sort, splice 같은 변경 메서드가 타입에서 사라지고, 인덱스 대입은 오류가 됩니다. map, filter, slice 같은 변경하지 않는 메서드는 계속 동작하며 일반 배열을 반환합니다.
TypeScript의 Readonly는 깊게 적용되나요?
아니요. Readonly<T>와 readonly는 최상위 속성만 보호하며, 안에 있는 중첩 객체와 배열은 여전히 바꿀 수 있습니다. 타입 수준에서 깊게 보호하려면 리터럴에 as const를 쓰거나 재귀적인 DeepReadonly<T> 타입을 작성하세요.
readonly가 런타임 변경을 막나요?
아니요. 타입은 지워지므로 readonly 속성은 런타임에 평범한 속성이고, 같은 객체를 가리키는 변경 가능한 참조(또는 일반 JavaScript)로 여전히 바꿀 수 있습니다. 런타임 보호가 필요하면 Object.freeze를 쓰세요. TypeScript는 그 결과를 Readonly<T>로 타입 지정합니다.