Article
🧩 TypeScriptのexactOptionalPropertyTypesで防ぐPATCH更新の事故
プロフィール編集APIで、こんなバグを見たことはないでしょうか。
- 表示名を変えただけなのに自己紹介まで消えた
- 自己紹介欄をクリアしたかったのに更新されなかった
- TypeScriptでは型エラーがないのに、実行時に
undefinedで既存値が上書きされた
共通する原因の一つは、「キーが存在しない」「キーはあるが値がundefined」「値がnull」を同じものとして扱うことです。
この記事ではexactOptionalPropertyTypesを使って、PATCH形式の部分更新を型と実行時検証の両方で安全に扱う方法を紹介します。PATCHはHTTPの「リソースの一部を変更する」メソッドですが、何を送れば削除になるかは各APIの契約次第です。以下では例として「キー省略=変更しない、null=自己紹介をクリアする」という独自のJSON契約を採用します。
まずは3つの違いを確認する
const omitted = {};
const explicitlyUndefined = { bio: undefined };
const clearValue = { bio: null };
console.log("bio" in omitted); // false
console.log("bio" in explicitlyUndefined); // true
console.log(JSON.stringify(omitted)); // "{}"
console.log(JSON.stringify(explicitlyUndefined)); // "{}"
console.log(JSON.stringify(clearValue)); // '{"bio":null}'
オブジェクトのin演算子は、指定したプロパティがあるかを調べます(継承したプロパティも対象です)。一方、JSON.stringifyはオブジェクト内のundefinedをJSONに出力しません。
このため、JavaScriptのオブジェクト上での「キーはあるが値がundefined」と、HTTPで送るJSON上での「キー自体がない」が途中で混ざることがあります。
また、単純なスプレッド構文({ ...current, ...patch })で更新すると、{ displayName: undefined }は既存の表示名をundefinedで上書きします。型だけでなく、サーバーに入る値の扱いまで決める必要があります。
exactOptionalPropertyTypesで型の意図を明確にする
TypeScriptの?は、プロパティが「存在しなくてもよい」という印です。exactOptionalPropertyTypesを有効にすると、?の型に暗黙のundefinedを追加して代入可能にする扱いが抑制されます。
tsconfig.jsonに次を設定します。
{
"compilerOptions": {
"strict": true,
"exactOptionalPropertyTypes": true
}
}
このオプションにはstrictNullChecksが必要です。上の例はstrictに含まれるstrictNullChecksを有効にしています。なお、この設定はJavaScriptの動作を変更するものではなく、コンパイル時の型チェックを強くするものです。
次のAPI契約を考えます。
displayName:省略なら維持、文字列なら変更、nullは不可bio:省略なら維持、文字列なら変更、nullならクリア
type ProfilePatch = {
displayName?: string;
bio?: string | null;
};
const rename: ProfilePatch = { displayName: "Bob" }; // OK
const clearBio: ProfilePatch = { bio: null }; // OK
const keepAll: ProfilePatch = {}; // 型上はOK
// @ts-expect-error: undefinedは「キー省略」と同じではない
const invalid: ProfilePatch = { displayName: undefined };
@ts-expect-errorは、その行で型エラーが起こることをテストする指示です。exactOptionalPropertyTypesを無効にすると、最後の行が許可されるため、逆に「想定したエラーが発生しない」という検証エラーになります。
注意点として、patch.displayNameを読むときは、キーが存在しない可能性があるため、依然としてstring | undefinedとして扱います。設定が変わるのは主に、任意プロパティへ何を書き込めるかです。
実務向け:受信したJSONを検証してから更新する
APIで受け取るJSONは外部入力です。TypeScriptの型宣言やas ProfilePatchだけでは、実際の入力が正しいかを保証できません。
以下は、標準のTypeScriptだけで動く小さな例です。patch-demo.tsに保存できます。
type Profile = { displayName: string; bio: string | null };
type ProfilePatch = { displayName?: string; bio?: string | null };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseProfilePatch(input: unknown): ProfilePatch {
if (!isRecord(input)) throw new Error("JSON object required");
const keys = Object.keys(input);
if (keys.length === 0 ||
keys.some((key) => key !== "displayName" && key !== "bio")) {
throw new Error("No valid changes or unknown property");
}
const patch: ProfilePatch = {};
if (Object.hasOwn(input, "displayName")) {
const displayName = input.displayName;
if (typeof displayName !== "string" || !displayName.trim()) {
throw new Error("displayName must be a non-empty string");
}
patch.displayName = displayName;
}
if (Object.hasOwn(input, "bio")) {
const bio = input.bio;
if (bio !== null && typeof bio !== "string") {
throw new Error("bio must be string or null");
}
patch.bio = bio;
}
return patch;
}
function applyPatch(current: Profile, patch: ProfilePatch): Profile {
return { ...current, ...patch };
}
const current: Profile = { displayName: "Alice", bio: "自己紹介" };
const updated = applyPatch(current, parseProfilePatch({ bio: null }));
if (updated.bio !== null || updated.displayName !== "Alice") {
throw new Error("Unspecified fields should not change");
}
for (const invalid of [
{ displayName: undefined },
{ bio: undefined },
{ xyz: "a" },
{}
]) {
let rejected = false;
try {
parseProfilePatch(invalid);
} catch {
rejected = true;
}
if (!rejected) throw new Error("Invalid patch was accepted");
}
// @ts-expect-error: undefined とキーの省略は違う
const invalidType: ProfilePatch = { displayName: undefined };
console.log("All checks passed");
コードのポイント
unknownは「まだ型を信用していない値」です。APIリクエストの本文は最初からProfilePatchだと決めつけず、実行時に検査します。Object.hasOwn(input, "bio")は、そのオブジェクトがbioというキーを直接持つかを確認します。値がnullでもundefinedでも、キーがあれば検査を実行できます。bio: nullは許可し、bio: undefinedは拒否します。nullを削除扱いにするのは、今回明示したAPI契約です。- 未知の項目・変更なしの空オブジェクトはエラーにしています。実務では必要に応じて許容範囲を決めてください。
- 検証後の
patchにだけスプレッド構文を使います。これにより、想定外のundefinedで既存値が上書きされることを防ぎます。
動作確認
TypeScriptがローカルの開発依存に入っているプロジェクトで、以下を実行します。
npx tsc --strict --exactOptionalPropertyTypes --target ES2022 --module commonjs patch-demo.ts
node patch-demo.js
npx tsc:プロジェクトのTypeScriptコンパイラーを実行します。--strict:nullの扱いを含む厳格な型チェックを有効にします。--exactOptionalPropertyTypes:任意プロパティへの明示的なundefined代入を厳密に扱います。--target ES2022:出力するJavaScriptの言語仕様を指定します。例のObject.hasOwnにも対応します。--module commonjs:Node.js向けのCommonJS形式で出力します。package.jsonが"type": "module"の環境では、プロジェクトのモジュール設定に合わせて調整してください。
期待する出力はAll checks passedです。上記と同じサンプルはTypeScript 5.8.3で型検査とNode.js 22.16.0での実行を確認しました。 これは確認に使った環境であり、TypeScriptの最新版を示すものではありません。
ハマりどころ:Partial<T>を使えば十分?
Partial<T>は、型Tのすべてのプロパティを任意にする便利な型です。
type Profile = {
id: string;
displayName: string;
bio: string | null;
};
type PatchCandidate = Partial<Profile>;
しかし、この場合はidまで更新可能な型になります。更新してはいけないIDなどがあるAPIでは、PATCH用の入力型を別途定義するかPickで更新可能項目だけを選ぶ方が意図を表現しやすくなります。
type Editable = Pick<Profile, "displayName" | "bio">;
type ProfilePatch = Partial<Editable>;
また、exactOptionalPropertyTypesを有効にしても、ネットワークから受け取るJSONは自動検証されません。バックエンド側の検証は省略できません。
どんな場面で使い分ける?
| 要件 | 推奨する表現 |
|---|---|
| 未指定なら既存値を維持する | name?: stringとキーの有無の確認 |
| 明示的にクリアできる | `bio?: string |
明示的なundefinedも有効な値として扱う |
必要な場合だけ`value?: string |
| 外部からJSONを受け取る | unknownから実行時に検証 |
| 更新対象外のフィールドがある | API専用DTO(データを受け渡すための型)を定義 |
結論は、「任意」=「undefinedを渡してよい」ではないということです。キー省略、null、実際のJSONの振る舞いをAPI契約として整理し、TypeScriptのチェックと実行時検証を組み合わせると、部分更新のバグを減らせます。