一部の項目だけを更新する機能——いわゆる部分更新を自作すると、いずれ必ず「値を空欄に戻したい」という要求が来る。担当者が異動・退職したのに古い電話番号が残っていると、それは情報ではなく事故のもとだ。更新処理に空文字列を渡せば消える。普通はそう考える。
ところが実行しても、値は変わらない。エラーは出ない。終了コードも0。成功のはずが、狙った更新だけが黙って無視される。他のフィールド(名前や部署名)は問題なく更新できるのに、なぜかフィールドを「空にする」操作だけが効かない——原因を追うと、更新条件の書き方そのものに落とし穴があった。
この記事で学べること
if (values.field)という真偽値チェックが、なぜ「空文字列でクリアする」操作を黙って無視してしまうのか- 「未指定(undefined)」と「明示的に空にする指示(空文字列)」を区別する
!== undefinedへの直し方 - クライアントで
undefinedを入れるとHTTP越しにキーごと消える理由と、受信側でObject.hasOwn()によるキー有無判定が要るケース - 「触らない / 空文字列にする / NULLにする」の3状態をどう表現するか(JSON Merge Patch・ORMのセマンティクス)
- PATCH/部分更新API全般に共通する「渡されなかった」と「空にしろと指示された」の区別という設計原則
前提条件
- Node.js/TypeScriptで、CLIやAPIのオプション引数をオブジェクト(
values.fieldのような形)で受け取っている - 更新対象のフィールドが文字列型で、「空文字列で値をクリアする」という運用を想定している
- 一部のフィールドだけを更新する部分更新(PATCH的な操作)を実装している
実装方法
バグの正体: 真偽値チェックは「空文字列」も除外する
問題の箇所は、CLIのupdateコマンドが受け取った引数から、DBに書き込むデータオブジェクトを組み立てている部分だった。各フィールドの更新条件が、次のような書き方になっていた。
// 修正前: 更新データの組み立て(企業レコードの一部フィールド)
const data: Record<string, unknown> = { updatedAt: new Date().toISOString() };
if (values.name) data.name = values.name;
if (values.phone) data.phone = values.phone;
if (values.fax) data.fax = values.fax;
if (values.email) data.email = values.email;
if (values.address) data.address = values.address;
if (values.url) data.url = values.url;
if (values["postal-code"]) data.postalCode = values["postal-code"];
if (values.phone)は「values.phoneが truthy なら更新する」という意味だ。ここで、CLIの引数パーサー(node:utilのparseArgs、オプション定義はtype: "string")が--phone ""を渡されたときにどう解釈するかを確認しておく必要がある。渡されなかった場合はvalues.phoneがundefinedになり、--phone ""と明示的に空文字列を渡した場合はvalues.phoneが""になる。この2つはCLIの引数としては明確に区別されている。なのにJavaScriptの真偽値評価では、両方ともfalse扱いに潰れてしまう。undefinedもfalsyだし、""もfalsyだからだ(ちなみに数値の0やbooleanのfalseを受け取るフィールドでも、まったく同じ理由で同じ事故が起きる。「0件に更新する」が無視される、という形で表面化する)。
つまり「フィールドを指定しなかった(undefined)」と「フィールドを明示的に空にしろと指示した(空文字列)」という、本来まったく違う2つの意図が、if (values.field)という書き方の時点で同じ「更新しない」という結果に押し流されてしまっていた。
そしてこの条件チェックは、企業レコードの更新だけでなく担当者・商談の更新処理にも同じ形で書かれていた。担当者の部署名、商談の次アクション、企業の代表者名——文字列でクリアしたくなるフィールドが、そろって同じ穴を持っていたことになる。
修正: 「渡されたかどうか」を!== undefinedで判定する
直し方は、真偽値チェックを「undefinedと等しくないか」という判定に置き換えるだけだ。
// 修正後: 同じ組み立てを `!== undefined` 判定に置き換える
const data: Record<string, unknown> = { updatedAt: new Date().toISOString() };
if (values.name !== undefined) data.name = values.name;
if (values.phone !== undefined) data.phone = values.phone;
if (values.fax !== undefined) data.fax = values.fax;
if (values.email !== undefined) data.email = values.email;
if (values.address !== undefined) data.address = values.address;
if (values.url !== undefined) data.url = values.url;
if (values["postal-code"] !== undefined) data.postalCode = values["postal-code"];
!== undefinedなら、values.phoneが""(空文字列)のときも条件はtrueになり、data.phone = ""としてDBに書き込まれる。一方、--phone自体を渡さなかった場合はvalues.phoneがundefinedのままなので、条件はfalseになり、そのフィールドはデータオブジェクトに含まれない——つまり既存の値がそのまま保持される。「渡されなかったフィールドは触らない」「渡されたフィールドはその値(空文字列を含む)で上書きする」という、部分更新として本来あるべき挙動になる。
同じ修正を担当者と商談の更新処理にも適用した。3つの更新コマンドにまたがって合計27ヶ所、まったく同じアンチパターンが横展開していた。最初に見つけた1ヶ所だけ直して満足していたら、担当者や商談情報のクリア操作だけが壊れたまま生き残るところだった。コピペで増えた条件チェックは、ほぼ例外なく同じ穴を持っている。
挙動の変化を表で確認する
--phoneオプションの状態ごとに、修正前と修正後で挙動がどう変わったかを整理するとこうなる。
--phoneの指定 | values.phone | 修正前 if (values.phone) | 修正後 if (values.phone !== undefined) |
|---|---|---|---|
| 指定なし | undefined | 更新しない(意図通り) | 更新しない(意図通り) |
--phone "03-1234-5678" | "03-1234-5678" | 更新する(意図通り) | 更新する(意図通り) |
--phone "" | "" | 更新しない(バグ) | 更新する(クリアされる) |
「指定なし」と「値あり」の2パターンでは修正前後とも同じ結果になるので、このバグはテストケースが3パターン目(空文字列での明示的クリア)を含んでいない限り気づきにくい。
HTTP越しに渡すとundefinedはキーごと消える
ここまではコマンドライン引数の話だった。同じ更新処理をHTTPのPATCHエンドポイントとして公開すると、注意点が1つ増える。JSONにundefinedという値が存在しないからだ。
JSON.stringify({ name: "Example", phone: undefined });
// → '{"name":"Example"}' … phone はキーごと消える
これは送信側の落とし穴だ。クライアントでphone: undefinedと書いて「クリアする指示」のつもりでいると、キーごと落ちてサーバには何も届かない(丁寧にundefinedを代入しても、その意図はネットワークを越えられない)。空にしたいなら""かnullを明示的に入れる必要がある。
一方受信側では、JSONをパースした結果に対してbody.phone !== undefinedはそのまま機能する。キーが無ければundefinedになり、""やnullが入っていれば条件は真になるからだ。CLIのときと同じ判定でよい。
分岐が要るのは、ボディがJSONパース結果とは限らない経路——JS内部で組み立てたオブジェクトをそのまま更新関数に渡すような場合だ。ここでは{ phone: undefined }という「キーはあるが値がundefined」の状態が作れてしまう。これを「指示あり」として扱いたいなら、値ではなくキーの有無を見る。
// 値で判定: JSONパース結果に対してはこれで十分
if (body.phone !== undefined) data.phone = body.phone;
// キーの有無で判定: 「キーはあるが値が undefined」も指示ありとして扱う場合
if (Object.hasOwn(body, "phone")) data.phone = body.phone;
キーの有無はin演算子("phone" in body)でも判定できるが、プロトタイプチェーンまで遡るため"toString" in bodyまでtrueになる。Object.hasOwn()のほうが意図に忠実で事故が少ない。
一歩先: 「触らない / 空にする / NULLにする」は3状態になる
カラムがnullableなら、クライアントの意図は2つではなく3つある。①そのフィールドに触らない、②空文字列を入れる、③NULLを入れる。!== undefined(あるいはObject.hasOwn)は①とそれ以外を分けるだけで、②と③は分けていない。では②と③の両方を表現したくなったら、どう設計すればいいのだろうか。
自前でルールを発明する前に見るべき、広く使われている既成の答えが2つある。
- JSON Merge Patch(RFC 7386): 「キーが無い=触らない、
null=そのキーを削除、それ以外=その値で上書き」と規約で定める。空文字列は普通の値として素直に通る代わりに、nullは「削除」に予約されるので「NULLという値を明示的に書き込む」意図は表現できなくなる。仕様が明快になるトレードオフだ。 - ORMのセマンティクス: Prismaの
updateはundefinedを渡されたフィールドを「更新しない」、nullを「NULLを書き込む」として扱う。つまり今回の!== undefined判定は、下層のORMがすでに採用している意味論とそのまま噛み合っている。自前で判定を書くときも、この2値の意味づけを踏襲しておくと層をまたいでも挙動がぶれない。
自作の更新処理で3状態が必要になったら、独自ルールを発明する前にまずJSON Merge Patchの規約を検討したほうがいい。クライアント側の実装者にとって既知の規約であることの価値は大きい。
動作確認
検証は必ず3パターンを通す。「値あり更新」「空文字列でクリア」「そのキーを含めない更新」だ。PATCHエンドポイントとして公開しているなら、手元でこう叩ける(CLIとして持っているなら、同じ3パターンをオプションの有無で置き換えればいい)。
# 1. 値ありで更新 → 入っていることを確認
curl -X PATCH http://localhost:3000/companies/example-corp \
-H 'Content-Type: application/json' -d '{"phone":"03-1234-5678"}'
curl -s http://localhost:3000/companies/example-corp | jq .phone
# → "03-1234-5678"
# 2. 空文字列でクリア(修正後の期待挙動)
curl -X PATCH http://localhost:3000/companies/example-corp \
-H 'Content-Type: application/json' -d '{"phone":""}'
curl -s http://localhost:3000/companies/example-corp | jq .phone
# → ""(クリアされている。修正前はここが "03-1234-5678" のまま残る)
# 3. phone キーを含めない更新が既存値を壊さないことも確認
curl -X PATCH http://localhost:3000/companies/example-corp \
-H 'Content-Type: application/json' -d '{"name":"Example Corp"}'
curl -s http://localhost:3000/companies/example-corp | jq .phone
# → "" のまま(キーを含めない = 触らない)
3番目の確認が特に重要だ。「クリアできるようにする」修正が「指定していないフィールドまで空にしてしまう」新しい退行を生んでいないかは、別途確認しておく必要がある。!== undefined判定は「渡されなかったフィールドに触らない」という性質を保ったまま「空文字列は明示的な指示として尊重する」という挙動を追加しているので、この2つは両立する。
注意点・Tips
if (values.field)は「未指定」と「空文字列」を区別できない: JavaScriptの真偽値評価ではundefined・""・0・nullはすべてfalsyになる。「そのフィールドが渡されたかどうか」を知りたいときに真偽値チェックを使うのは筋が違う。- 部分更新の条件チェックは
!== undefinedが基本形: 「渡されなかったら触らない、渡されたらその値(空文字列含む)で上書きする」という部分更新(PATCH的な操作)の意味論を素直に表現できる。 - HTTP越しに
undefinedを送ると消える:JSON.stringifyは値がundefinedのキーを丸ごと落とす。クライアント側で「クリアするつもり」でundefinedを入れても、サーバには何も届かない。空にしたいなら""かnullを明示する。 - 「キーはあるが値がundefined」を区別したいときだけ
Object.hasOwn(): JSONパース結果に対しては!== undefinedで十分。JS内部で組み立てたオブジェクトを直接渡す経路がある場合だけ、キーの有無で判定する。 - NULLも扱うなら状態は3つになる: 「触らない / 空文字列 / NULL」を区別する必要が出たら、独自ルールを作る前にJSON Merge Patch(RFC 7386)の規約を検討する。
- 同じアンチパターンは横展開しやすい: 1つのコマンドで見つかったら、同じ更新ロジックを持つ他のコマンドも必ず確認する。コピペで増えた条件チェックは同じ穴を持っていることが多い。
- テストケースには「空文字列で明示的にクリアする」パターンを含める: 「未指定」と「値あり」の2パターンだけでは、このバグは再現しない。
まとめ
「電話番号を空欄に更新したのに元の値が残る」という一見わかりにくい不具合の裏には、if (values.field)という真偽値チェックが「未指定」と「明示的な空文字列」を同じ「更新しない」に押し流してしまう、という典型的なfalsy判定の見落としがあった。修正自体は!== undefinedへの置き換えだけで済む。ただしHTTP越しではundefinedを入れたキーがJSON.stringifyで丸ごと消えるので、クリアの意図は""かnullで明示しないと届かない。NULLまで扱うなら状態は3つに増え、JSON Merge Patchのような既成の規約が要る。
要するに、部分更新の設計とは「値が何か」ではなく「そのフィールドについて指示があったか」を先に決める作業だ。この2つを混同しない、という一点さえ徹底しておけば、この手のバグは最初から入り込まない。



