🧗‍♂️

JavaScriptでの空判定が難しすぎる

2023/12/23に公開
2

問題提起

オブジェクトの空判定には次の方法をずっと使っている。

let isBlank = function(value) {
  return value === undefined || value === null || value === false ||
    (typeof value === "object" && Object.keys(value).length === 0) ||
    (typeof value === "string" && value.trim().length === 0)
}

ここでいうオブジェクトとは連想配列のことではなく「得体の知れない何かの値」を指す。

そこに IntersectionObserver のインスタンスを、

let obj = new IntersectionObserver(() => {}, {})
obj  // => IntersectionObserver {root: null, rootMargin: '0px 0px 0px 0px', thresholds: Array(1), delay: 0, trackVisibility: false, …}

渡すと、

isBlank(obj)  // => true

空判定されてめちゃくそはまった。

obj は「ある」のに isBlank(obj) は「空」だという。これはどういうことだろう?

とりあえずググる

と、こちらのサイトで7つもの方法が紹介されていた。

https://jp-seemore.com/web/3764/

  1. Object.keys(obj).length === 0
  2. JSON.stringify(obj) === "{}"
  3. for + obj.hasOwnProperty
  4. Object.getOwnPropertyNames(obj).length === 0
  5. Object.entries(obj).length === 0
  6. Reflect.ownKeys(obj).length === 0
  7. lodash で _.isEmpty(obj)

これらを順番に試していく。

方法1. Object.keys(obj).length === 0

Object.keys(obj).length === 0  // => true

ググるとこの方法がもういやっていうほど溢れ出てくる。そのせいか ChatGPT に聞いてもこれを進めてくる。

この方法は Ruby で言えば obj.methods.size == 0 とするようなもので、かなり馬鹿げているが、クラスから new したインスタンスと連想配列の区別が曖昧な JavaScript ではこのようにするしかないのだと思われる。

結果: 空と判定される

方法2. JSON.stringify(obj) === "{}"

JSON.stringify(obj) === "{}"  // => true

JSON.stringify は参照循環エラーで痛い目にあった記憶しかないので、空判定ごときに不定値を丸投げなんて恐くてできないんだが、

結果: 空と判定される

方法3. for + obj.hasOwnProperty

let isEmpty = function(obj) {
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      return false
    }
  }
  return true
}
isEmpty(obj)  // => true

Object.keys(obj) と変わらないような気もするけど、

結果: 空と判定される

方法4. Object.getOwnPropertyNames(obj).length === 0

Object.getOwnPropertyNames(obj).length === 0  // => true

ChatGPT を問い詰めると観念してこの方法を紹介してくるのだが、

結果: 空と判定される

方法5. Object.entries(obj).length === 0

Object.entries(obj).length === 0  // => true

これも Object.keys(obj) と変わらないけど、

結果: 空と判定される

方法6. Reflect.ownKeys(obj).length === 0

Reflect.ownKeys(obj).length === 0  // => true

ES6の新機能らしく期待したが、

結果: 空と判定される

方法7. lodash で _.isEmpty(obj)

import _ from "lodash"
_.isEmpty(obj)  // => true

最終手段。世の知恵を結集した最強のロジックで判定してくれているはずなので最後はこれしかない。

結果: 空と判定される

underscore にしてみるが、

import _ from "underscore"
_.isEmpty(obj)  // => true

結果: 空と判定される

いったんまとめ

7(+1)つの方法すべて誤判定だった。そこで対策を考える。

間違った対策: あるか? で判定する

「空か?」で行なっていた判定をすべて反転し「あるか?」判定して反転する。

あるか?
let isPresent = function(value) {
  return value !== undefined || value !== null || value !== false ||
    (typeof value === "object" && Object.keys(value).length !== 0) ||
    (typeof value === "string" && value.trim().length !== 0)
}
空か?
let isBlank = function(value) {
  return !isPresent(value)
}

こうすると、

let obj = new IntersectionObserver(() => {}, {})
obj !== undefined  // => true

が真になるため意図した通りの結果になる。

isPresent(obj)  // => true
isBlank(obj)    // => false

と、思いきや、これはもうめちゃくちゃで

isPresent("")  // => true
isBlank("")    // => false

空文字列も「ある」と判定されるようになってしまう。

正しい対策1: constructor を見る

下の記事では constructor を参照していたので参考にしてみる。

元のコードでは typeof value の結果がなんでもかんでも "object" になるのが問題[1]だった。そこで value.constructor === Object で比較してから Object.keys で判定するように変更する。

-  typeof value === "object" && Object.keys(value).length === 0
+  value.constructor === Object && Object.keys(value).length === 0

のように変更したのがこれで、

let isBlank = function(value) {
  return value === undefined || value === null || value === false ||
    (value.constructor === Object && Object.keys(value).length === 0) ||
    (typeof value === "string" && value.trim().length === 0)
}

IntersectionObserver インスタンスの obj.constructor.name は "IntersectionObserver" なので

let obj = new IntersectionObserver(() => {}, {})
obj.constructor.name  // => 'IntersectionObserver'

次の判定は、

obj.constructor === Object && Object.keys(obj).length === 0  // => false

false になる。

だから isBlank も正しく、

isBlank(obj)  // => false

false になる。[2]

ただし、

https://zenn.dev/link/comments/384323244bcb91

のコメントによると、

let obj = Object.create(null)

は、

obj.constructor  // => undefined

となって obj.constructor === Object が成立せず、判定がおかしくなる。これは Ruby でも Class.new.new.class.name は nil なので、割り切ってそんな特殊なケースは無視してもいいかもしれない (投げやり)

補足: Date クラスにも同じ問題がある

今回 IntersectionObserver のインスタンスが空判定されて気づいたのだけど、Date のインスタンスも、

let obj = new Date()
Object.keys(obj).length === 0  // => true

同様に空判定される。このケースでも obj.constructor は

obj.constructor.name  // => "Date"

Object ではない ので、obj.constructor === Object の条件を入れてから Object.keys すれば空判定を免れる。

obj.constructor === Object && Object.keys(obj).length === 0  // => false

https://chaika.hatenablog.com/entry/2020/11/11/111100 によると、

空文字列 "", 空配列 [], 数値, Boolean, new Date() や function, Symbol , RegExp, class などは Object.keys() が空配列になるので、object.constructor === Object で弾く必要がある。

とのこと。それ早く教えてよ。

正しい対策1の対策: for in が回らなかったら空とする

これはコメントで助言頂いた方法を改良したものになる。

まず上の方法3で試したコードから、

let isEmpty = function(obj) {
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      return false
    }
  }
  return true
}
isEmpty(obj)  // => false

hasOwnProperty のチェックを取っ払う。

let isEmpty = function(obj) {
  for (const key in obj) {
    return false
  }
  return true
}
isEmpty(obj)  // => true

そして、プロパティが一つも出てこなかったら「空」と判定する。どこで定義されたプロパティか? なんて関係ない。出てきたらもう空じゃない。これなら constructor を見ないため Object.create(null) も「空」と判定できる。

ところで for in でプロパティが出てくるのに Object.keys ではなぜ出てこなかったのだろう? それは Object.keys が for of に反応するものしか抽出しないためだそう。なお for (const key of obj) {}Uncaught TypeError: obj is not iterable のエラーになる。

この要領で最初の isBlank を直すとこうなる。

let isBlank = function(value) {
  return value === undefined || value === null || value === false ||
    (typeof value === "object" && isPropertyEmpty(value)) ||
    (typeof value === "string" && value.trim().length === 0)
}
let isPropertyEmpty = function(value) {
  for (const key in value) {
    return false
  }
  return true
}

あらためて IntersectionObserver のインスタンスを渡すと

let obj = new IntersectionObserver(() => {}, {})
obj           // => IntersectionObserver {root: null, rootMargin: '0px 0px 0px 0px', thresholds: Array(1), delay: 0, trackVisibility: false, …}
isBlank(obj)  // => false

「空ではない」と正しく判定するようになった。もうこれで問題ないだろう。

念のため Date のインスタンスも渡してみると、

let obj = new Date()
obj           // => Sat Dec 23 2023 17:28:19 GMT+0900 (日本標準時)
isBlank(obj)  // => true

はぁ???

Date のインスタンスは「空」と判定されてしまった。どうみても obj は「ある」のだが Object.keys は空だし for in も回らないのでどうしょうもない。

それなら constructor を見る方法はどうだろう? (既視感)

他の対策: 判定しない

もう何も信じられなくなっているので判定しない。

obj とあったとき、それがクラスから new したインスタンスなのか、連想配列なのかを自分で把握しておく。そしてクラスから new したインスタンスが「空か?」の判定は(真偽値が本当に必要であれば)たんに obj == null[3] とする。それだけ。

まとめ

  • オブジェクトの空判定には Object.keys(obj).length === 0 を使えと喧伝されている
    • このときのオブジェクトが何を指しているのかが曖昧
    • たしかに連想配列のことをオブジェクトと言っているなら正しい
    • しかし得体の知れないなんらかの値のことをオブジェクトと言っているのだと解釈してしまうとおかしなことになる
  • たとえば IntersectionObserver のインスタンスは「ある」のに空判定されてしまう
    • Object.keys(obj) が空になるため
  • だったら obj.constructor === Object の判定を含めたらどう?
    • IntersectionObserver のインスタンスの constructor は Object ではないためうまくいく
    • しかし Object.create(null) で作った値には constructor がないので Object (連想配列)判定されなくなる
  • それなら for in が回るかだけで判定するのはどう?
    • これなら Object.create(null) で作った値も「空」と判定できる
    • IntersectionObserver のインスタンスも「空ではない」と判定できる
    • これですべて解決か?
      • Date のインスタンスは for in が回らない
      • だったら obj.constructor === Object の判定を含めたらどう? (以降ループ)
  • あきらめる手もある
    • 得体の知れない値の空判定なんかやめよう
      • どのような状態が空と言えるのか判断できないため (言い逃れ)
脚注
  1. typeof null まで "object" になるのに typeof "" が "string" なのはわけがわからん ↩︎

  2. あとで気づいたが、この時点で isBlank は空配列 [] を「空ではない」と判定してしまうため constructor === Array なら obj.length === 0 の条件を追加しないといけない ↩︎

  3. obj == null は obj === undefined の判定を含む ↩︎

Discussion

standard softwarestandard software

Object.keysは、for...of のと同じなので、for...in でループしてプロパティのキー数を求める関数を作って判定しておくといいのではないでしょうか?

詳細このあたりに書いています。

for...of - JavaScript | MDN
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/for...of#for...of_と_for...in_との違い

megetonmegeton

アドバイスありがとうございます
記事に加筆しました