Using Loose Equality in JS/TS

-- Loose Equality allows different kinds to become an equal kind. I've been feeling, blogposts treat loose equality in JavaScript as a kind of black magic because people do not master it. It isn't supposed to be esoteric magic. I am not involved in browser development but I conducted my tests with Firefox and Chrome and I read the MDN pages. You can check the examples. I say, make full potential of Javascript's capabilities, intentionally and knowingly. It can save us from some coding overhead, even when the original definition is sometimes flawed. The loose equality can make code more concise… This means, if you know the types of the operands. Therefore, the loose equality is particularly useful with TypeScript, because it can show you the types of expressions. True, even TypeScript lacks understanding of loose equality. TypeScript blatantly lies when it says 0 == [] would always return false (because the opposite is true). Let's dive in. Loose comparisons in Typescript We have two options to use loose equality in TypeScript. 1ˢᵗ We can suppress type checking on operands const neverTrue: boolean = 0 != [] as any; if (neverTrue) console.assert(false); or use the "non-null assertion" operator ! on nullable values to turn off errors or warnings while using the loose equality. Or 2ⁿᵈ, we can use explicit type casts such as const neverTrue = 0 != Number([]); if (neverTrue) console.assert(false); Once both sides use the same type, you can just use == or !=. Misuse is hard to miss in TypeScript. TypeScript complains when you are using loose equality not like a strict equality. For type coercions, Web Devs might know using the (unary) plus + for number coercion or !! (like Boolean()) or ""+ (like String()). You can cast floating point numbers to integers with specific arithmetic operations such as 0 |, -1 &, ~, > 0 instead of using Math.floor(). Coercing Symbols has always ended in a TypeError in my tests. Note: MDN distinguishes the "number coercion" with + from "numercial coercion" with Number(), because + (only the unary operator) does not work with BigInt. This is a "Big Fail", even the unary minus works! []+ works just the way as ""+ does because [] is coerced to an empty string first. Summary For your tl;dr function equality(a, b) { if (isNaN(a) || isNaN(b)) return false; if (a === null) a = undefined; if (b === null) b = undefined; if (typeof a === typeof b) return a === b; if (a === undefined || b === undefined) return false; if (typeof a === "boolean") a = Number(a); if (typeof b === "boolean") b = Number(b); if (typeof a === "number" || typeof b === "number") return equality( Number(a), Number(b) ); if (typeof a === "string" || typeof b === "string") return String(a) === String(b); return Object.is(a, b); } JavaScript also features BigInts but for brevity I ignore them. We can treat them as if they were numbers which have no comma, infinity and NaN… and no unary plus. Technically, by definition of the MDN pages, the coercion first tries [Symbol.toPrimitive]() (for objects, arrays, functions) else tries .valueOf() else tries .toString() but two strings will always be compared literally (byte by byte). Comparing equal types Let's begin with a note, that Typescript treats == pretty much like === except for ignoring type errors about nulls and undefineds. == is the same as === when both operands are of the same type, for all distinguished JavaScript types. This comparison is especially strict among objects, functions and symbols. Remember: arrays and sets and null are also objects! Two objects, functions or symbols only satisfy == or === when they are the same! "The same" means, any changes made to one side would also be at the other side of the equation at the same time. In the equation, there is only one single value compared with itself. undefined has its own special type. null is just an object constant without any properties. (There is only ever one constant null object.) It goes as far as leading to {} !== {} and [] !== []. Both sides of the equality sign are equal, but separate objects. Comparing distinct types This is actually the interesting part where we can avoid writing code. When I experiment in the browser console with different test cases, this image emerges: operands of boolean type are replaced by 0 or 1, false is compared as 0, true as 1 (like in C) literally it means, booleans inside non-boolean operands (such as arrays) are not replaced. I am serious here, false != "false" and false == "0" !! if one of the operands is a number (or boolean), it coerces the other operand to a number and checks equality again probably indirectly by coercing it's string note the subsection about NaNs below, any non-coercible thing becomes a NaN (except for Symbols) empty strings are 0! Therefore, empty arrays as well. values witho

May 10, 2025 - 17:16
 0
Using Loose Equality in JS/TS

mirrors showing multiple different equalities

-- Loose Equality allows different kinds to become an equal kind.

I've been feeling, blogposts treat loose equality in JavaScript as a kind of black magic because people do not master it. It isn't supposed to be esoteric magic.

I am not involved in browser development but I conducted my tests with Firefox and Chrome and I read the MDN pages. You can check the examples.

I say, make full potential of Javascript's capabilities, intentionally and knowingly. It can save us from some coding overhead, even when the original definition is sometimes flawed. The loose equality can make code more concise…

This means, if you know the types of the operands. Therefore, the loose equality is particularly useful with TypeScript, because it can show you the types of expressions.

True, even TypeScript lacks understanding of loose equality. TypeScript blatantly lies when it says 0 == [] would always return false (because the opposite is true).

Let's dive in.

Loose comparisons in Typescript

We have two options to use loose equality in TypeScript. 1ˢᵗ We can suppress type checking on operands

const neverTrue: boolean =  0 != [] as any;
if (neverTrue)
  console.assert(false);

or use the "non-null assertion" operator ! on nullable values to turn off errors or warnings while using the loose equality.

Or 2ⁿᵈ, we can use explicit type casts such as

const neverTrue = 0 != Number([]);
if (neverTrue)
  console.assert(false);

Once both sides use the same type, you can just use == or !=. Misuse is hard to miss in TypeScript. TypeScript complains when you are using loose equality not like a strict equality.

For type coercions, Web Devs might know using the (unary) plus + for number coercion or !! (like Boolean()) or ""+ (like String()). You can cast floating point numbers to integers with specific arithmetic operations such as 0 |, -1 &, ~, << 0 or >> 0 instead of using Math.floor(). Coercing Symbols has always ended in a TypeError in my tests.

Note: MDN distinguishes the "number coercion" with + from "numercial coercion" with Number(), because + (only the unary operator) does not work with BigInt. This is a "Big Fail", even the unary minus works! []+ works just the way as ""+ does because [] is coerced to an empty string first.

Summary

For your tl;dr

function equality(a, b)
{
  if (isNaN(a) || isNaN(b))
    return false;

  if (a === null)
    a = undefined;
  if (b === null)
    b = undefined;

  if (typeof a === typeof b)
    return a === b;

  if (a === undefined || b === undefined)
    return false;

  if (typeof a === "boolean")
    a = Number(a);
  if (typeof b === "boolean")
    b = Number(b);
  if (typeof a === "number" || typeof b === "number")
    return equality( Number(a), Number(b) );

  if (typeof a === "string" || typeof b === "string")
    return String(a) === String(b);

  return Object.is(a, b);
}

JavaScript also features BigInts but for brevity I ignore them. We can treat them as if they were numbers which have no comma, infinity and NaN… and no unary plus.

Technically, by definition of the MDN pages, the coercion

  • first tries [Symbol.toPrimitive]() (for objects, arrays, functions)
  • else tries .valueOf()
  • else tries .toString()
  • but two strings will always be compared literally (byte by byte).

Comparing equal types

Let's begin with a note, that Typescript treats == pretty much like === except for ignoring type errors about nulls and undefineds.

== is the same as === when both operands are of the same type, for all distinguished JavaScript types.

This comparison is especially strict among objects, functions and symbols.

Remember: arrays and sets and null are also objects!

Two objects, functions or symbols only satisfy == or === when they are the same! "The same" means, any changes made to one side would also be at the other side of the equation at the same time. In the equation, there is only one single value compared with itself.

undefined has its own special type. null is just an object constant without any properties. (There is only ever one constant null object.)

It goes as far as leading to {} !== {} and [] !== []. Both sides of the equality sign are equal, but separate objects.

Comparing distinct types

This is actually the interesting part where we can avoid writing code.

When I experiment in the browser console with different test cases, this image emerges:

  • operands of boolean type are replaced by 0 or 1, false is compared as 0, true as 1 (like in C)

    • literally it means, booleans inside non-boolean operands (such as arrays) are not replaced.
    • I am serious here, false != "false" and false == "0" !!
  • if one of the operands is a number (or boolean), it coerces the other operand to a number and checks equality again

    • probably indirectly by coercing it's string
    • note the subsection about NaNs below, any non-coercible thing becomes a NaN (except for Symbols)
    • empty strings are 0! Therefore, empty arrays as well.
    • values without .toString() either become NaN or throw a type error
  • else if one of both is a string, it coerces the other operand to a string and compares both

    • the standard behaviour boils down to .toString()
    • arrays are turned into a comma-separated list of element strings (without surrounding brackets!)
      • unfortunately, these representations are different from the string cast String() and different from the coercion of ==
      • it can be useful for deep-equality of number strings where shape of multi-dimensional arrays does not matter
  • else, if coercion to a "primitive value" (such as number or string) was not applicable, it compares whether both operands are the same one object. (Functions, arrays and objects won't be equal if not the same.)

    • functions of different objects of equal class will be equal; they come from the same prototype
    • The equality check will not work with Date objects! Instead, see the takeaways below.
  • one special type of value, the Symbol, can only be equal by reference (not to numbers or strings)

A careful reader might have noticed that some values cannot be coerced, they don't have a valueOf() or toString(). And standard objects without toString() will throw a type error. But null and undefined are treated as mutually equal and unequal to everything else.

All of this has interesting implications, such as:

[0] == false, "0" == false, 0 == false, [] == false, [false] == "false",
[0] == 0, "" == 0, ".00" == 0, [] == 0, [" 000.000 "] == 0, [[[[[0]]]]] == "0"

but

[false] != 0, [false] != false, "false" != false, [[[[[0]]]]] == " .0 "

Comparing NaN

This is a special case.

No matter what you do, if you ever compare == of a NaN, it will produce a false. != will always be true. It goes completely against the other rules.

Right, NaN === NaN is completely false. But! Object.is(NaN, NaN) ignores this special rule and returns true.

It seems, JavaScript treats a != b as !(a == b) so that NaN != NaN returns true.

It seems contrived that we have to use isNaN() for checking NaNs.
It is rather exceptional that of all things JavaScript follows the IEEE752 standard here, whereas it otherwise violates the mathematical standard semantics of equivalence relations.

Despite that, it is useful to have an absorbing element for equality. NaN originally is an error value in IEEE752. The IEEE task force probably thought, any subsequent computations based on it wouldn't make sense, therefore would result in NaN as well. Starting computations based on NaN wouldn't make sense then and could be skipped.

Relational comparisons

While everybody complains about ==, rarily people seem to notice or point out, that there is no strict version for the other relational comparisons <, <=, > and >=. Typescript checks them as if they were strict.

Comparing Dates, strings (lexicographically) and numbers is pretty straight forward but what happens with a mix of types? It is similar to loose equality but will not compare object references.

Did you know that `"  1 " < "1"` is `true` but `"  1 " < 1` is `false`? The first one is compared lexicographically, the second one with number coercion.

NaNs always compare as false (except when using !=).
null becomes a 0, undefined becomes a NaN.

Indeed, ({} <= {}) and [] <= {} is true for some reason but neither ({} < {}) nor ({} == {}) is true. [] >= {} is false as well.

The comparison compares number coercions, otherwise string coercions and it won't compare by reference. NaN and undefined are the special case which always gives false.

null is a special case among the special cases. It is treated like a 0 in relational comparisons. This means 0 <= null && 0 >= null and even null <= [] and null <= "" is true but 0 == null is false!

Symbols cannot be compared with relational operators, it will cause a type error.

Takeaways

  • a < b is not equivalent to !(a >= b) (you cannot rewrite negations when you have undefined or NaN values)
  • a <= b is not equivalent to a < b || a == b
  • a == b is not equivalent to a <= b && a >= b (equality/inequality and relational operators are different things)
  • a <= b || a >= b is not always true!

undefined == undefined is true but undefined <= undefined || undefined >= undefined is false.
On the other hand, [] <= [""] && [] >= [""] is true but [""] != [].

  • Booleans generally behave like 0 or 1 like in loose equality.

  • if you need equality, rather than an identity check, you can make use of a <= b && b >= a

    • works great for Dates
    • works in specific cases for Objects with custom toString() function
    • arrays with only numbers, booleans, undefined and null elements work.
    • use JSON.stringify(), it is safer but not entirely safe
      • functions, symbols cannot be turned into JSON and are like "erased"
      • BigInts cause an error
      • the order of object properties can be non-deterministic
    • ultimately, a 3rd party library such as lodash is required for equality checking of objects
    • does not work with arrays whose elements have commas , in their string representation
    • nested arrays are not visible in the relational comparison
  • relational comparisons of Symbols will crash your script

Outro

Anything written here is meant for the good of others. If you know something better or something is awry, leave a constructive reminder in the comments.