はじめに
オブジェクトの特定のフィールドを更新する処理、こんなふうに書いていませんか?
const updated = { ...product, price: 699 };
スプレッド構文で十分に見えますが、これだと以下のミスがすべてコンパイルを通ってしまいます。
-
idのような変更してはいけないフィールドを上書きしてしまう -
stockに文字列"100"を入れてしまう(number なのに) -
visibilityに存在しない値"deleted"を入れてしまう
この記事では、Generics・Omit・keyof・Indexed Access Type を組み合わせて、こうしたミスをコンパイル時に防ぐ 型安全な updater 関数 の作り方を紹介します。
本記事は @KaraBharat氏のツイート を参考に、実務での活用方法を補足してまとめたものです。
ベースとなる型
ECサイトの商品データを想定します。
type Visibility = "draft" | "active" | "archived" | "out_of_stock";
interface Product {
readonly id: string;
name: string;
price: number;
stock: number;
category: string;
visibility: Visibility;
}
id は readonly にしてあります。DBの主キーなど、一度設定したら変更すべきでないフィールドです。
型安全な updater を作る
ステップ1: 更新可能なフィールドを制限する
まず、更新対象から id を除外した型を用意します。
type Updatable<T> = Omit<T, "id">;
Omit<Product, "id"> は id を除いた { name: string; price: number; stock: number; ... } になります。これにより、更新関数に id を渡せなくなります。
ステップ2: key と value の整合性を型で保証する
type SafeUpdate<T> = <K extends keyof Updatable<T>>(
key: K,
val: Updatable<T>[K],
) => T;
ここがポイントです。Generics の K を使って、引数 key に指定したフィールド名に対応する型だけが val に入るようにしています。
-
keyが"price"ならvalはnumber -
keyが"visibility"ならvalは"draft" | "active" | "archived" | "out_of_stock"
K extends keyof Updatable<T> なので、"id" は候補にすら出てきません。
ステップ3: 実装
const createUpdater =
<T,>(p: T): SafeUpdate<T> =>
(key, val) => ({ ...p, [key]: val });
createUpdater は元のオブジェクトを受け取り、SafeUpdate<T> 型の関数を返すファクトリです。
使ってみる
const product: Product = {
id: "prod_001",
name: "Computer Table",
price: 899,
stock: 50,
category: "electronics",
visibility: "draft",
};
const updateProduct = createUpdater(product);
✅ 正しい使い方(コンパイルOK)
const launched = updateProduct("visibility", "active"); // ✅
const discounted = updateProduct("price", 699); // ✅
const restocked = updateProduct("stock", 200); // ✅
いずれも、key に対して正しい型の value が渡されています。
❌ コンパイルエラーになるケース
updateProduct("id", "prod_999");
// ❌ Omit<T, "id"> に "id" は存在しないのでエラー
updateProduct("stock", "100");
// ❌ stock は number 型なので string は代入不可
updateProduct("visibility", "deleted");
// ❌ Visibility 型に "deleted" は含まれないのでエラー
すべてコンパイル時にキャッチされます。 ランタイムで壊れるのを待つ必要がありません。
なぜスプレッド構文だけでは不十分なのか
比較してみましょう。
// スプレッド構文(型チェックが緩い)
const bad = { ...product, id: "hacked", stock: "100" };
// → コンパイルは通るが、id が書き換わり、stock が string になる
// SafeUpdate(型チェックが厳密)
updateProduct("id", "hacked"); // ❌ コンパイルエラー
updateProduct("stock", "100"); // ❌ コンパイルエラー
スプレッド構文はオブジェクト型全体に対する代入互換性だけを見るので、個々のフィールドの制約が効きにくいのです。SafeUpdate はフィールド単位で型を検証するため、こうした問題を防げます。
実務での使いどころ
状態管理の更新ロジック
Redux の reducer や Zustand の set で、フィールド単位の更新を安全に行いたいとき。
// Zustand のストア内で
const useProductStore = create<ProductStore>((set) => ({
product: initialProduct,
update: (key, val) => {
const updater = createUpdater(get().product);
set({ product: updater(key, val) });
},
}));
API の PATCH リクエストのバリデーション
フロントエンドでリクエストを組み立てる段階で、送信するフィールドと値の整合性を保証できます。
async function patchProduct<K extends keyof Updatable<Product>>(
id: string,
key: K,
val: Updatable<Product>[K],
) {
await fetch(`/api/products/${id}`, {
method: "PATCH",
body: JSON.stringify({ [key]: val }),
});
}
patchProduct("prod_001", "price", 699); // ✅
patchProduct("prod_001", "price", "高い"); // ❌
管理画面のインライン編集
テーブルのセルをクリックして編集するUIで、カラムごとに入力値の型を制限できます。
function handleCellEdit<K extends keyof Updatable<Product>>(
column: K,
newValue: Updatable<Product>[K],
) {
// column が "price" なら newValue は必ず number
// column が "visibility" なら newValue は必ず Visibility
}
型の仕組みまとめ
この updater パターンで使われているテクニックを整理します。
| テクニック | 役割 |
|---|---|
Omit<T, "id"> |
更新不可フィールドを除外 |
keyof Updatable<T> |
更新可能なキーだけをUnion型として取得 |
K extends keyof ... |
Generics で key を推論させる |
Updatable<T>[K] |
Indexed Access Type で key に対応する value の型を取得 |
readonly id |
そもそも変更すべきでない意図を型で表現 |
これらを組み合わせることで、「どのフィールドを」「どんな値で」更新できるかをコンパイル時に制御できるようになります。
単純なスプレッド構文で済む場面も多いですが、更新対象に制約があるケース(id を触らせたくない、特定の値しか許可しないなど)では、この updater パターンが効果的です。