🧊

[TypeScript] Object.freeze() の代替としての as const satisfies の活用

に公開

アルダグラムでソフトウェアエンジニアとして活動している松田です。

TypeScriptでオブジェクトを定義する場合、プロパティの変更を防ぎたいことがあります。
例えば、以下のような設定オブジェクトがあるとします。

const appConfig = {
  API_URL: "https://example.com",
  API_VERSION: "v2",
  TIMEOUT: 5000,
}

JSで長らく開発してきた人にとっては、Object.freeze() を使うのが自然なアプローチかもしれません。

// 実行時に不変にする
const appConfig = Object.freeze({
  API_URL: "https://example.com",
  API_VERSION: "v2",
  TIMEOUT: 5000,
})

// 実行時エラー
appConfig.TIMEOUT = 3000 // TypeError

しかし、TypeScriptを活用する場合は、より良い方法があります。
本稿では、TypeScriptにおける不変オブジェクトの定義方法について解説し、Object.freeze() がなぜ原則不要なのかを説明します。

要約

  • as const satisfies を積極的に活用
    • コンパイル時の型安全性はこれで十分に担保できる
  • Object.freeze() などは原則不要
    • TypeScript環境であれば、実行時の不変性チェックは冗長
    • ただし、型のないJavaScriptライブラリと連携する場合など、例外的に有用なケースもある

const でオブジェクトを宣言しただけでは不変にならない

重要な点から確認しましょう。
まず、const でオブジェクトを宣言しても、プロパティは変更可能です。
const は再代入を禁止するだけで、オブジェクトの「中身」(プロパティ) までは保護しません。

const appConfig = {
  API_URL: "https://example.com",
  API_VERSION: "v2",
  TIMEOUT: 5000,
}

// 問題なく変更できてしまう
appConfig.TIMEOUT = 3000

TypeScriptの機能の活用

as const (constアサーション)

as const を使うと、オブジェクトのプロパティがすべて readonly になり、型もリテラル型(string ではなく "https://example.com")として推論されます。

const appConfig = {
  API_URL: "https://example.com",
  API_VERSION: "v2",
  TIMEOUT: 5000,
} as const

// 以下のような型エラーになる
// Cannot assign to 'TIMEOUT' because it is a read-only property.
appConfig.TIMEOUT = 3000

typeof appConfig は以下のようになります。

{
  readonly API_URL: "https://example.com";
  readonly API_VERSION: "v2";
  readonly TIMEOUT: 5000;
}

このように、as const はTypeScriptで不変オブジェクトを定義するための基本的な手法です。

satisfies 演算子の追加

as const は強力ですが、型の整合性を保証するわけではありません。

例えば、以下のような Config 型があるとします。

type Config = {
  API_URL: string;
  API_VERSION: 'v1' | 'v2';
  TIMEOUT: number;
}

appConfigConfig 型を満たしているかをチェックしたい場合、as const だけでは不十分です。
そこで、satisfies 演算子を組み合わせて使います。

const appConfig = {
  API_URL: "https://example.com",
  API_VERSION: "v2",
  TIMEOUT: 5000,
} as const satisfies Config // ここにsatisfies を追加

as const satisfies Config によって、以下が保証されます。

  • appConfig のプロパティはすべて readonly になる
  • appConfigConfig 型を満たしていることがコンパイル時に検証される
  • appConfig のプロパティはリテラル型として推論される

Object.freeze() は不要?

Object.freeze() は、実行時にオブジェクトの変更を禁止するJavaScriptの組み込み関数です。
プロパティの追加・削除・変更等を防ぎます。

const appConfig = {
  API_URL: "https://example.com",
  API_VERSION: "v2",
  TIMEOUT: 5000,
}

Object.freeze(appConfig)

// TypeScriptのコンパイルは通過するが、実行時に以下のようなエラーが発生する
// TypeError: Cannot assign to read only property 'TIMEOUT' of object '#<Object>'
appConfig.TIMEOUT = 3000

しかし、TypeScriptを使用している場合、as const satisfies を使うことで、コンパイル時に同様の不変性を保証できます。
よって、TypeScript環境下では Object.freeze() は冗長です。
むしろ、ネストされたオブジェクトや配列に対しては Object.freeze() が浅い凍結しか行わないため、誤解を招く可能性があります。
(as const はネストされたプロパティもすべて readonly にします)

const appConfig = {
  API_URL: "https://example.com",
  OPTIONS: {
    retry: true,
    timeout: 3000,
  },
}

Object.freeze(appConfig)

// 以下は実行時エラーにならない (浅い凍結のため)
appConfig.OPTIONS.retry = false

Object.freeze() が有効な例外的なケース

とはいえ、Object.freeze() が完全に無用というわけではありません。
TypeScriptコンパイラの監視外、つまり、実行時(コンパイル後)にオブジェクトが変更されるリスクがある場合には、Object.freeze() が有効です。

型のないJavaScriptライブラリと連携する場合

型定義(.d.ts)がない、もしくは不完全なJavaScriptライブラリと連携する場合です。
ライブラリ内部でオブジェクトのプロパティを書き換えられる可能性があります。

import { someOldJsLibrary } from "some-old-lib"

const settings = {
  mode: "safe",
} as const

// settings オブジェクトがライブラリ内部で変更されるリスクがある
someOldJsLibrary(settings)

console.log(settings.mode) // "unsafe" <- !?

このような場合、Object.freeze() を使って実行時にオブジェクトを不変にすることが有効です。

import { someOldJsLibrary } from "some-old-lib"

const settings = Object.freeze({
  mode: "safe",
} as const)

// 実行時に不変にする
someOldJsLibrary(settings)

console.log(settings.mode) // "safe"

型アサーションによる不正な変更を防ぎたい場合

場合によっては、開発者が as を使って型チェックを回避し、不正にオブジェクトを変更してしまうことがあります。

const appConfig = {
  API_URL: "https://example.com",
  TIMEOUT: 5000,
} as const satisfies Config

(appConfig as any).TIMEOUT = 1000 // コンパイルは通る

上記は極端な例ですが、ライブラリやフレームワークとの兼ね合いで、型アサーションが利用されるケースもあります。
極力避けるべきではありますが、こうしたシチュエーションでの応急処置として Object.freeze() の利用を検討できます。

// Object.freeze() を併用しておけば、上記コードは実行時エラーになる
const appConfig = Object.freeze({
  API_URL: "https://example.com",
  TIMEOUT: 5000,
} as const satisfies Config)

(appConfig as any).TIMEOUT = 1000 // 実行時に TypeError

まとめ

TypeScriptで不変オブジェクトを定義する際は、as const satisfies を活用することで、コンパイル時に型安全性と不変性を効果的に担保できます。
Object.freeze() は原則不要ですが、型のないJavaScriptライブラリとの連携や、型アサーションによる不正な変更を防ぎたい場合など、例外的に有用なケースも存在します。

本稿が、オブジェクトの不変性に関する理解や、古いJavaScriptコードとの連携や改修に役立てば幸いです。

参考資料

https://zenn.dev/tonkotsuboy_com/articles/typescript-as-const-satisfies
https://zenn.dev/atusi/articles/a466687d7257ae
https://lorem-co-ltd.com/wrong-object-freeze/
https://typescriptbook.jp/reference/values-types-variables/object/object-literal
https://typescriptbook.jp/reference/values-types-variables/const-assertion
https://typescriptbook.jp/reference/values-types-variables/satisfies
https://typescriptbook.jp/reference/values-types-variables/object/readonly-property
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze


もっとアルダグラムのエンジニア組織を知りたい人は、ぜひ下記の情報もチェックしてみてください!

アルダグラム Tech Blog

Discussion