TypeScriptでオブジェクトにはReadonlyではなくDeepReadonlyを使うべき

TypeScriptのReadonlyとは

TypeScriptのReadonlyは、オブジェクトのプロパティを読み取り専用(変更不可)にするためのユーティリティ型です。

TypeScript
type User = {
  name: string
  age: number
}

const user: Readonly<User> = {
  name: 'Sato',
  age: 30
}

user.age = 31 // ❌ エラー

実態としては以下のような型で変更されているのと同じなので、nameを読み取り専用にして、ageを変更可能にしたい場合はreadonlyでできます。

TypeScript
type ReadonlyUser = {
  readonly name: string
  readonly age: number
}

Readonlyは浅い(shallow)読み取り専用

type ReadonlyUserを見ての通り、Readonlyは浅い(shallow)読み取り専用なので、以下のような形の場合はエラーにはなりません。

TypeScript
type User = {
  name: string
  age: number,
  hobby: string[]
}

const user: Readonly<User> = {
  name: 'Taro',
  age: 30,
  hobby: ['soccer', 'reading']
}

user.hobby.push('cooking')
console.log(user)
TypeScript
type User2 = {
  name: string
  age: number,
  hobby: {
    type1: string
    type2: string
  }
}

const user2: Readonly<User2> = {
  name: 'Taro',
  age: 30,
  hobby: {
    type1: 'soccer',
    type2: 'reading',
  }
}

user2.hobby.type2 = 'cooking'

DeepReadonlyの型を作成する

オブジェクトのネストが浅くない場合は、DeepReadonlyの型を作成して使用すれば前述のような読み取り専用なのに変更してもエラーにならない問題を解決できます。

方法としては、以下のコードでtype DeepReadonlyを定義し、DeepReadonly<User>のように使用するだけです。

TypeScript
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends (...args: unknown[]) => unknown
    ? T[P]
    : T[P] extends object
    ? DeepReadonly<T[P]>
    : T[P]
}

type User = {
  name: string
  age: number,
  hobby: string[]
}

type User2 = {
  name: string
  age: number,
  hobby: {
    type1: string
    type2: string
  }
}

const user: DeepReadonly<User> = {
  name: 'Taro',
  age: 30,
  hobby: ['soccer', 'reading']
}

const user2: DeepReadonly<User2> = {
  name: 'Taro',
  age: 30,
  hobby: {
    type1: 'soccer',
    type2: 'reading',
  }
}

// ❌️ エラーが表示される。
user.hobby.push('cooking')
console.log(user)

// ❌️ エラーが表示される。
user2.hobby.type2 = 'cooking'
console.log(user2)

↑このコードをDeepReadonlyからReadonlyに変えると、エラーが表示されません。

TypeScriptでReadonlyをオブジェクトに付けて、読み取り専用になっていると勘違いされているケースは結構多いです。

最初は浅い(shallow)読み取り専用であっても、あとから拡張されて深い構造になると、Readonlyでは対応できなくなる場合があります。

そのため、最初からDeepReadonlyを使用することをオススメします。