【TypeScript】イミュータブルなオブジェクト設計で注意すべきポイント

はじめに

この記事では、TypeScriptにおいて、イミュータブルなオブジェクト設計とその扱い方についての基本を解説します。

当記事で解説する内容は、大きく分けて以下の通りです。
- オブジェクトをイミュータブルにする方法
- イミュータブルなオブジェクトの部分更新

TypeScriptのコードは以下リンク先の公式サイトで試すことができます。
▼TypeScript Playground
https://www.typescriptlang.org/play

オブジェクトをイミュータブルにする方法

オブジェクトをイミュータブルにする(プロパティの値の再代入を防ぐ)方法として、 readonly 修飾子があります。
readonly 修飾子を付けたプロパティは読み取り専用(再代入不可)になります。

type User = {
  readonly id: string;
}

const user: User = {
  id: '001'
};

user.id = '002';  // 再代入不可

readonly 修飾子はプロパティ単位なので、全てのプロパティに付けなければなりません。

type User = {
  readonly id: string;
  readonly name: string;
  readonly age: string;
}

これではプロパティの数が多くなるほど手間になってしまいます。
そこで、一括してプロパティを読み取り専用にする方法があります。
Readonly<T> というユーティリティ型を使います。

type User = Readonly<{
  id: string;
  name: string;
  age: number;
}>

const user: User = {
  id: '001',
  name: 'ほげ丸ぴよ太',
  age: 30
};

user.id = '002';  // 再代入不可
user.name = 'foo野bar子'  // 再代入不可
user.age = 25;  // 再代入不可

ここで注意しなければならないことがあります。
Readonly<T>(および readonly 修飾子)による読み取り専用の効果は、再帰的ではないということです。(=浅い不変性)
つまり、ネストしたオブジェクトのプロパティには読み取り専用の効果は適用されず、再代入できてしまいます。

type User = Readonly<{
  id: string;
  profile: {
    name: string;
    age: number;
  };
}>

const user: User = {
  id: '001',
  profile: {
    name: 'ほげ丸ぴよ太',
    age: 30
  }
};

user.profile.name = 'foo野bar子'  // 再代入できてしまう!
user.profile.age = 25;  // 再代入できてしまう!

したがって、オブジェクトごとに Readonly<T> を付ける必要があります。

type User = Readonly<{
  id: string;
  // profile オブジェクトにも Readonly<T>を付ける。
  profile: Readonly<{
    name: string;
    age: number;
  }>;
}>

const user: User = {
  id: '001',
  profile: {
    name: 'ほげ丸ぴよ太',
    age: 30
  }
};

user.profile.name = 'foo野bar子'  // 再代入不可
user.profile.age = 25;  // 再代入不可

上記のようにオブジェクトの型宣言をネストしてしまうと、Readonly<T> の付け忘れがないかをレビューする際に見逃してしまいそうです。
オブジェクト単位ごとに型定義を外だしし、ネストしないようにすることで程度は確認漏れを防げるでしょうか。

type User = Readonly<{
  id: string;
  profile: Profile;
}>

type Profile = Readonly<{
  name: string;
  age: number;
}>

const user: User = {
  id: '001',
  profile: {
    name: 'ほげ丸ぴよ太',
    age: 30
  }
};

user.profile.name = 'foo野bar子'  // 再代入不可
user.profile.age = 25;  // 再代入不可

まとめ

  • オブジェクトをイミュータブルにするには Readonly<T> ユーティリティ型を使う。
  • Readonly<T> による読み取り専用効果は再帰的ではない(浅い不変性)ことに注意。
type User = Readonly<{
  id: string;
  profile: Profile;
}>

type Profile = Readonly<{
  name: string;
  age: number;
}>

イミュータブルなオブジェクトの部分更新

イミュータブルなオブジェクトのプロパティには再代入ができません。
では、特定のプロパティの値を変更したい場合はどうしたらよいでしょうか。
その答えは、「プロパティの値を変更した新しいオブジェクトを生成する」ことです。

TypeScriptにおいてはスプレッド構文によるコピーを用いて実現します。

type User = Readonly<{
  id: string;
  name: string;
  age: number
}>

const oldUser: User = {
  id: '001',
  name: 'ほげ丸ぴよ太',
  age: 30
};

const newUser: User = { ...oldUser, age: 31 };

console.log(newUser);  // {"id": "001", "name": "ほげ丸ぴよ太", "age": 31}

newUser のプロパティのうち age だけが変更され、その他のプロパティは oldUser からコピーされていることが分かります。


また、スプレッド構文によるオブジェクトのコピーでは、コピー元とコピー先の参照が異なることを確認しておきます。
以下のコードでは、プロパティの値を変更しないでそのままコピーしています。

type User = Readonly<{
  id: string;
  name: string;
  age: number
}>

const oldUser: User = {
  id: '001',
  name: 'ほげ丸ぴよ太',
  age: 30
};

const newUser: User = { ...oldUser };  // そのままコピー

console.log(oldUser === newUser);  // false(参照が異なる)

ただし、ここで注意が必要です。
スプレッド構文によるコピーはシャロ―コピー(浅いコピー)です。
ネストしたオブジェクトの参照は同じになっています。

type User = Readonly<{
  id: string;
  profile: Profile;
}>

type Profile = Readonly<{
  name: string;
  age: number;
}>

const oldUser: User = {
  id: '001',
  profile: {
    name: 'ほげ丸ぴよ太',
    age: 30
  }
};

const newUser: User = { ...oldUser, id: '002' };

console.log(oldUser === newUser);  // false(コピー直下のオブジェクトの参照は異なるが・・・)
console.log(oldUser.profile === newUser.profile);  // true(ネストしたオブジェクトの参照は同じ!)

これでは、イミュータブルなオブジェクト設計が崩れてしまいます。

不変性が崩れてしまう例としては、型情報が失われてしまうことで readonly の効果が効かなくなる場合が挙げられます。

/**
 * 誕生日を迎えたユーザーの年齢をプラス1する。
 */
const celebrateBirthday = (user: any) => {
  // この関数の user は any 型なので、プロパティを直接変更してもエラーにならない。
  user.profile.age++;
}

// newUser が誕生日を迎える。
celebrateBirthday(newUser);  // newUser は {"age": 31} になった

console.log(oldUser);  // oldUser も {"age": 31} になってしまった!

上記の例は極端ですが、実コーディングにおいて、オブジェクトの利用先で型情報を失わせるような使い方をされていないかどうかを保証するのは難しいと思います。

外部境界(HTTPやLocalStorageなど)とのやり取りで使用する JSON.parse() なんかはいい例ですね。


では、どうしたら不変性を保証できるでしょうか。

その答えの1つとして、スプレッド構文によるオブジェクトのコピーをディープコピー(深いコピー)にすることが挙げられます。

そこで、structuredClone() を用いてオブジェクトをディープコピーします。

const newUser: User = { ...structuredClone(oldUser), id: '002' };

// newUser が誕生日を迎える。
celebrateBirthday(newUser);

console.log(oldUser);  // oldUser は {"age": 30} のままである

ディープコピーしたことで、ネストしたオブジェクトも含めて参照が異なるので、型情報が失われるような使い方をされても不変性が維持できるようになりました。

もっとも、ディープコピーにはデメリットもあります。(パフォーマンスコストやメモリ使用量など)

不変性を保証する手段は他にも考えられると思うので、場面に応じて使い分けていきたいですね。

まとめ

  • イミュータブルなオブジェクトを部分更新するには、スプレッド構文を用いる。
  • スプレッド構文はシャローコピーであることに注意。
  • structuredClone() によるディープコピーを併用することで、オブジェクトの不変性を維持する。
type User = Readonly<{
  id: string;
  profile: Profile;
}>

type Profile = Readonly<{
  name: string;
  age: number;
}>

const oldUser: User = {
  id: '001',
  profile: {
    name: 'ほげ丸ぴよ太',
    age: 30
  }
};

const newUser: User = { ...structuredClone(oldUser), id: '002' };

まとめ

TypeScript におけるイミュータブルなオブジェクト設計の基本と注意点について解説しました。

不変性は型だけで完全に保証できるものではなく、設計や実装の工夫が重要であることがわかりました。

本記事が、日々の開発の参考になれば嬉しく思います。

お問い合わせ

サービスに関するご相談やご質問などこちらからお問い合わせください。

03-55107260

受付時間 10:00〜17:00