【実務・中級編】 Mapped Typesにおけるreadonly修飾子の付与と削除 – TypeScript実践ガイド

おい、みんな、調子はどうだ? TypeScriptの深淵を覗き込んでいるかい?

フロントエンド開発の現場で日々コードと格闘している君たちなら、一度は「このオブジェクト、勝手に書き換えられちゃ困るんだよなぁ」とか、「APIから受け取ったデータはImmutableに扱いたいけど、フォームで編集するときだけは可変にしたい…」なんて悩みにぶつかったことがあるはずだ。

そんな時に、TypeScriptの型システムが持つ強力な武器の一つが、今回スポットライトを当てる`readonly`修飾子、そしてそれを動的に制御する`Mapped Types`だ。単なる構文を覚えるだけじゃなく、その背後にある思想や、現場でどう活用するかの「泥臭い知恵」まで、俺と一緒に深掘りしていこうじゃないか。

TypeScriptの基本の「き」:`readonly`修飾子とは

まずは軽くおさらいだ。`readonly`修飾子ってのは、その名の通り「読み取り専用」を意味する。オブジェクトのプロパティにこれを付与すると、そのプロパティは初期化時以外は変更できなくなる。

例えば、こんな感じだ。

interface UserProfile {
readonly id: number; // idは一度設定したら変更不可
name: string;
readonly email?: string; // emailも読み取り専用(オプショナル)
}

const user: UserProfile = {
id: 1,
name: “Alice”,
email: “alice@example.com”
};

user.name = “Bob”; // OK: nameは読み取り専用ではない
// user.id = 2; // エラー: ‘id’ は読み取り専用プロパティです。
// user.email = “new@example.com”; // エラー: ‘email’ は読み取り専用プロパティです。

これは非常に強力で、意図しない副作用(Side Effect)を防ぎ、コードの予測可能性を高める上で欠かせない。特に、Immutableなデータ構造を重視する関数型プログラミングのパラダイムと相性が良い。

究極の柔軟性:Mapped Typesで`readonly`を付与する

さて、ここからが本題だ。もし、ある既存の型があって、その全てのプロパティを読み取り専用にしたい場合、一つ一つ`readonly`を書いていくのは面倒だし、型の変更があったときに追従しにくい。

そこで登場するのが`Mapped Types`だ。これは、既存の型をベースに新しい型を生成する機能で、その際に各プロパティに動的に修飾子を付与したり、型を変更したりできる。

全てのプロパティを`readonly`にする最も基本的なパターンは、TypeScriptの標準ユーティリティ型である`Readonly`と同じ仕組みだ。

interface Product {
id: string;
name: string;
price: number;
description?: string;
}

// Mapped Typesを使って、Product型を完全に読み取り専用にする型を定義
// [P in keyof T] で、元の型Tの全てのプロパティPをループする
// readonly を付与することで、各プロパティを読み取り専用にする
type ImmutableProduct = {
readonly [P in keyof T]: T[P];
};

// ImmutableProduct型をProduct型に適用
type ReadonlyProduct = ImmutableProduct;

const productData: Product = {
id: “p001”,
name: “Widget Pro”,
price: 99.99
};

const immutableProduct: ReadonlyProduct = productData;

// immutableProduct.name = “Super Widget”; // エラー: ‘name’ は読み取り専用プロパティです。
console.log(immutableProduct.name); // “Widget Pro”

この`ImmutableProduct`型は、まさに「汎用的にどんなオブジェクトでも読み取り専用にするぜ!」という意図を明確に示している。

現場での活用シーン:APIレスポンスの保護

考えてみてくれ。APIから取得したデータは、基本的にはUIに表示するだけで、クライアント側で勝手に変更されるべきではないことが多い。もし変更が必要なら、それは新しいリクエストとしてサーバーに送るべきだろう。

// APIから取得するであろうユーザーデータの型
interface ApiUserResponse {
id: number;
username: string;
email: string;
createdAt: Date;
updatedAt: Date;
}

// Mapped Typesを使って、APIレスポンスをImmutableな型に変換
// これにより、UI層で誤ってデータを変更する事故を防ぐ
type ReadonlyApiUser = {
readonly [P in keyof ApiUserResponse]: ApiUserResponse[P];
};

const fetchedUser: ReadonlyApiUser = {
id: 123,
username: “ts-master”,
email: “master@example.com”,
createdAt: new Date(),
updatedAt: new Date(),
};

// fetchedUser.username = “new-master”; // エラー: ‘username’ は読み取り専用プロパティです。
console.log(`Fetched user: ${fetchedUser.username}`); // 意図せず変更される心配がない

これで、不注意によるバグを一つ潰せるわけだ。小さなことのように思えるかもしれないが、大規模なアプリケーションになればなるほど、こういう堅牢性がシステムの信頼性を大きく左右する。

柔軟性の極致:Mapped Typesで`readonly`を削除する

さて、今度はその逆のシナリオを考えてみよう。「元々は`readonly`だったんだけど、特定のユースケースでは可変にしたいんだよな」というケースだ。例えば、設定オブジェクトは通常読み取り専用だが、ユーザーが設定画面で編集する時だけは可変にしたい、といった具合だ。

こんな時も`Mapped Types`が活躍する。`readonly`修飾子を付与するのと同じように、`-readonly`という構文を使うことで、動的に`readonly`修飾子を「削除」できるんだ。

// もともと全てのプロパティが読み取り専用な設定オブジェクトの型
interface ReadonlyAppSettings {
readonly theme: “dark” | “light”;
readonly language: “en” | “ja”;
readonly notificationsEnabled: boolean;
}

// Mapped Typesを使って、ReadonlyAppSettingsのreadonly修飾子を削除し、可変にする型を定義
// -readonly を付与することで、各プロパティからreadonly修飾子を削除する
type Mutable = {
-readonly [P in keyof T]: T[P];
};

// ReadonlyAppSettingsを可変にする
type EditableAppSettings = Mutable;

const currentSettings: ReadonlyAppSettings = {
theme: “dark”,
language: “en”,
notificationsEnabled: true,
};

// currentSettings.theme = “light”; // エラー: ‘theme’ は読み取り専用プロパティです。

// 編集用のオブジェクトを作成(スプレッド構文でコピーし、型をEditableAppSettingsにする)
const draftSettings: EditableAppSettings = { …currentSettings };
draftSettings.theme = “light”; // OK: 読み取り専用ではなくなったので変更可能
draftSettings.notificationsEnabled = false; // OK

console.log(`Original theme: ${currentSettings.theme}`); // dark
console.log(`Edited theme: ${draftSettings.theme}`); // light

この`Mutable`型もまた、TypeScriptの標準ユーティリティ型である`Partial`や`Required`と同様に、非常に汎用的に使える強力な型定義だ。

現場での活用シーン:フォーム入力との連携

ReactやVueのようなコンポーネントベースのフレームワークを使っている場合、フォーム入力の処理でこのテクニックが光る。

親コンポーネントからImmutableな設定オブジェクトを受け取り、それを子コンポーネント(フォーム)で編集可能にしたい場合、わざわざ新しいインターフェースを定義する必要がなくなる。

// 親コンポーネントが子に渡す設定型(読み取り専用)
interface AppConfig {
readonly apiEndpoint: string;
readonly enableTelemetry: boolean;
readonly maxRetries: number;
}

// フォームコンポーネントが受け取る編集可能な設定型
// AppConfigからreadonlyを削除して、Mutableにする
type EditableAppConfig = Mutable;

// 例: フォームコンポーネント
function ConfigForm(props: { config: EditableAppConfig }) {
// props.config はこのコンポーネント内で変更可能
props.config.enableTelemetry = false; // OK
// …フォームのonChangeハンドラなどで値を更新
}

// 親コンポーネント
const initialConfig: AppConfig = {
apiEndpoint: “https://prod.api.com”,
enableTelemetry: true,
maxRetries: 3,
};

// フォームに渡す前に、AppConfigをEditableAppConfigに変換
// スプレッド構文でシャローコピーを作成し、元のオブジェクトへの直接的な変更を防ぐ
const editableConfig: EditableAppConfig = { …initialConfig };

// ConfigForm({ config: initialConfig }); // タイプエラーになる可能性が高い(もしフォームが変更を期待するなら)
ConfigForm({ config: editableConfig }); // OK

これで、`AppConfig`のImmutableな性質は保ちつつ、`ConfigForm`内では柔軟なデータ操作が可能になる。まさに「痒い所に手が届く」機能だ。

ブラウザはどう処理するのか? TypeScriptと実行時の関係性

さて、ここで君たちが気にするであろう「ブラウザが裏側でどう処理しているか」という点にも触れておこう。

結論から言えば、`readonly`修飾子はTypeScriptのコンパイル時にのみ意味を持つ概念だ。

TypeScriptコードは、最終的にJavaScriptにトランスパイル(変換)されてブラウザで実行される。このトランスパイルの過程で、`readonly`のような型に関する情報は全て取り除かれる。なぜなら、JavaScriptには`readonly`という概念がないからだ。

つまり、ブラウザがJavaScriptコードを実行する際には、`readonly`が付いていたプロパティも、ただの通常のプロパティとして扱われる。

// TypeScriptコード
interface MyData {
readonly value: number;
}
const data: MyData = { value: 10 };
// data.value = 20; // TypeScriptコンパイラがエラーを出す

// 上記がJavaScriptにトランスパイルされると(例えばES2015+)
// const data = { value: 10 };
// data.value = 20; // JavaScriptではエラーにならない(値が書き換えられる)

じゃあ`readonly`を使う意味がないのか?と問われれば、答えは断固としてNOだ。

`readonly`は、開発段階で「このプロパティは変更しないでね」という強い意図をコードと型システムを通じて表明するためのものだ。TypeScriptコンパイラが、その意図に反するコードを見つけたらエラーとして教えてくれる。これにより、実行時エラーや意図しないバグを防ぐという、非常に重要な役割を果たしている。

ブラウザはTypeScriptの型を知らない。だが、我々開発者は型を知っている。この型の知識を使って、開発プロセスをより安全で堅牢にする。それがTypeScriptの真価であり、`readonly`修飾子もその一翼を担っているんだ。

現場のベストプラクティスと注意点

1. 無闇に使わない

`readonly`は強力だが、何でもかんでも読み取り専用にすれば良いというものではない。本当にそのプロパティが変更されるべきではないのか、変更される可能性がある場合はどうするのかを、設計段階でしっかり考えることが重要だ。過剰な`readonly`は、コードの柔軟性を損ない、かえって開発を複雑にする可能性もある。

2. シャローコピーとディープコピーを意識する

`readonly`修飾子は、あくまでオブジェクトのプロパティ自体が読み取り専用になるだけで、そのプロパティが参照する「内部のオブジェクト」まではImmutableにしない。

interface NestedData {
readonly items: { name: string }[];
}

const data: NestedData = {
items: [{ name: “item1” }, { name: “item2” }]
};

// data.items = []; // エラー: ‘items’ は読み取り専用プロパティです。

// しかし、items配列の中身は変更できる
data.items[0].name = “changed item”; // OK!
console.log(data.items[0].name); // “changed item”

もしネストされたオブジェクトや配列も完全にImmutableにしたい場合は、`ReadonlyArray`や`Readonly`を再帰的に適用するカスタム`Mapped Types`を定義するか、Immutable.jsやImmer.jsのようなライブラリの利用も検討しよう。

3. 型定義の可読性を保つ

複雑な`Mapped Types`を連鎖させすぎると、後からコードを読んだ人が理解に苦しむことになる。必要に応じて別名(Type Alias)をつけたり、コメントで意図を明確にしたりと、可読性を意識した型定義を心がけよう。

まとめ

`readonly`修飾子の付与と削除は、TypeScriptの型システムを柔軟かつ堅牢に使うための、まさに「マスターピース」と言えるテクニックだ。

これを使いこなせば、意図しない副作用を防ぎ、コードの予測可能性と保守性を格段に向上させることができる。APIから取得したデータの安全性を確保したり、フォームでのデータ編集を柔軟に扱ったりと、その活用シーンは無限大だ。

型定義は、単なる「お約束」じゃなく、開発チームの共通認識であり、未来の自分へのメッセージでもある。この力を最大限に活用して、バグが少なく、読みやすい、そして何より「頼りになる」プロダクトを作っていこうぜ!

じゃあ、また次の深掘りで会おう!

コメント

タイトルとURLをコピーしました