Repository: amzn/ion-js
Pull request #669 (2021-01-08 21:39:44)

Implementation of equals() API for dom.Value
**Issue #663**

**Description of changes:**
This PR implements the `equals` API for `dom.Value`. 

**Terminology:**
- _Strict equivalence_: 
It refers to Ion data model equivalence as defined above and by [Ion Specification](http://amzn.github.io/iondocs/docs/spec.html)

- _Structural or non strict equivalence_: 
It follows the same rules as strict equivalence, except that
   - Annotations are not considered, and
   - Timestamps that represent the same instant in time are always considered equivalent.

**equals():**
This method can be used on each `dom.Value` for checking it's equivalence with other dom.Value. It return a boolean value representing whether both values are equal or not.

*Options:*
This method has additional options for equivalence as below:
`epsilon` is used by Float for an equality with given epsilon precision. (Default: null)
`strict` specifies whether equality should be strict or non strict. (Default: true)

**Tasks:**

1. Created an `equals()` method in dom.Value for implementation.
2. Created an `ionEquals()` method that cam be used as a helper method inside equals() for each dom.Value specific equivalence logic.
3. Implemented `ionEquals()` for each `dom.Value` type.

**Test:**
Created equivalence unit tests for each `dom.Value` Ion data types.


Patch:
diff --git a/src/dom/Boolean.ts b/src/dom/Boolean.ts
index 2981b5c..d5d272e 100644
--- a/src/dom/Boolean.ts
+++ b/src/dom/Boolean.ts
@@ -58,4 +58,44 @@ export class Boolean extends Value(
     writer.setAnnotations(this.getAnnotations());
     writer.writeBoolean(this.booleanValue());
   }
+
+  _ionEquals(
+    other: any,
+    options: {
+      epsilon?: number | null;
+      ignoreAnnotations?: boolean;
+      ignoreTimestampPrecision?: boolean;
+      onlyCompareIon?: boolean;
+    } = {
+      epsilon: null,
+      ignoreAnnotations: false,
+      ignoreTimestampPrecision: false,
+      onlyCompareIon: true,
+    }
+  ): boolean {
+    let isSupportedType: boolean = false;
+    let valueToCompare: any = null;
+    if (options.onlyCompareIon) {
+      // `compareOnlyIon` requires that the provided value be an ion.dom.Boolean instance.
+      if (other instanceof Boolean) {
+        isSupportedType = true;
+        valueToCompare = other.booleanValue();
+      }
+    } else {
+      // We will consider other Boolean-ish types
+      if (typeof other === "boolean" || other instanceof global.Boolean) {
+        isSupportedType = true;
+        valueToCompare = other.valueOf();
+      }
+    }
+
+    if (!isSupportedType) {
+      return false;
+    }
+
+    if (this.booleanValue() !== valueToCompare) {
+      return false;
+    }
+    return true;
+  }
 }
diff --git a/src/dom/Decimal.ts b/src/dom/Decimal.ts
index 5e4d702..76de3d9 100644
--- a/src/dom/Decimal.ts
+++ b/src/dom/Decimal.ts
@@ -6,6 +6,7 @@ import {
   FromJsConstructorBuilder,
 } from "./FromJsConstructor";
 import { Value } from "./Value";
+import JSBI from "jsbi";
 
 const _fromJsConstructor: FromJsConstructor = new FromJsConstructorBuilder()
   .withClasses(ion.Decimal)
@@ -56,4 +57,47 @@ export class Decimal extends Value(
     writer.setAnnotations(this.getAnnotations());
     writer.writeDecimal(this.decimalValue());
   }
+
+  _ionEquals(
+    other: any,
+    options: {
+      epsilon?: number | null;
+      ignoreAnnotations?: boolean;
+      ignoreTimestampPrecision?: boolean;
+      onlyCompareIon?: boolean;
+    } = {
+      epsilon: null,
+      ignoreAnnotations: false,
+      ignoreTimestampPrecision: false,
+      onlyCompareIon: true,
+    }
+  ): boolean {
+    let isSupportedType: boolean = false;
+    let valueToCompare: any = null;
+    if (options.onlyCompareIon) {
+      // `compareOnlyIon` requires that the provided value be an ion.dom.Decimal instance.
+      if (other instanceof Decimal) {
+        isSupportedType = true;
+        valueToCompare = other.decimalValue();
+      }
+    } else {
+      // We will consider other Decimal-ish types
+      if (other instanceof ion.Decimal) {
+        // expectedValue is a non-DOM Decimal
+        isSupportedType = true;
+        valueToCompare = other;
+      } else if (other instanceof Number || typeof other === "number") {
+        isSupportedType = true;
+        // calling numberValue() on ion.Decimal is lossy and could result in imprecise comparisons
+        // hence converting number to ion.Decimal for comparison even though it maybe expensive
+        valueToCompare = new ion.Decimal(other.toString());
+      }
+    }
+
+    if (!isSupportedType) {
+      return false;
+    }
+
+    return this.decimalValue().equals(valueToCompare);
+  }
 }
diff --git a/src/dom/Float.ts b/src/dom/Float.ts
index fe1ff77..432fbb4 100644
--- a/src/dom/Float.ts
+++ b/src/dom/Float.ts
@@ -35,4 +35,51 @@ export class Float extends Value(Number, IonTypes.FLOAT, _fromJsConstructor) {
     writer.setAnnotations(this.getAnnotations());
     writer.writeFloat64(this.numberValue());
   }
+
+  _ionEquals(
+    other: any,
+    options: {
+      epsilon?: number | null;
+      ignoreAnnotations?: boolean;
+      ignoreTimestampPrecision?: boolean;
+      onlyCompareIon?: boolean;
+    } = {
+      epsilon: null,
+      ignoreAnnotations: false,
+      ignoreTimestampPrecision: false,
+      onlyCompareIon: true,
+    }
+  ): boolean {
+    let isSupportedType: boolean = false;
+    let valueToCompare: any = null;
+    if (options.onlyCompareIon) {
+      // `compareOnlyIon` requires that the provided value be an ion.dom.Float instance.
+      if (other instanceof Float) {
+        isSupportedType = true;
+        valueToCompare = other.numberValue();
+      }
+    } else {
+      // We will consider other Float-ish types
+      if (other instanceof global.Number || typeof other === "number") {
+        isSupportedType = true;
+        valueToCompare = other.valueOf();
+      }
+    }
+
+    if (!isSupportedType) {
+      return false;
+    }
+
+    let result: boolean = Object.is(this.numberValue(), valueToCompare);
+
+    if (options.epsilon != null) {
+      if (
+        result ||
+        Math.abs(this.numberValue() - valueToCompare) <= options.epsilon
+      ) {
+        return true;
+      }
+    }
+    return result;
+  }
 }
diff --git a/src/dom/Integer.ts b/src/dom/Integer.ts
index e93d975..d372132 100644
--- a/src/dom/Integer.ts
+++ b/src/dom/Integer.ts
@@ -79,4 +79,41 @@ export class Integer extends Value(Number, IonTypes.INT, _fromJsConstructor) {
       writer.writeInt(this._bigIntValue);
     }
   }
+
+  _ionEquals(
+    other: any,
+    options: {
+      epsilon?: number | null;
+      ignoreAnnotations?: boolean;
+      ignoreTimestampPrecision?: boolean;
+      onlyCompareIon?: boolean;
+    } = {
+      epsilon: null,
+      ignoreAnnotations: false,
+      ignoreTimestampPrecision: false,
+      onlyCompareIon: true,
+    }
+  ): boolean {
+    let isSupportedType: boolean = false;
+    let valueToCompare: any = null;
+    if (options.onlyCompareIon) {
+      // `compareOnlyIon` requires that the provided value be an ion.dom.Integer instance.
+      if (other instanceof Integer) {
+        isSupportedType = true;
+        valueToCompare = other.numberValue();
+      }
+    } else {
+      // We will consider other Integer-ish types
+      if (other instanceof global.Number || typeof other === "number") {
+        isSupportedType = true;
+        valueToCompare = other.valueOf();
+      }
+    }
+
+    if (!isSupportedType) {
+      return false;
+    }
+
+    return JSBI.EQ(this.numberValue(), valueToCompare);
+  }
 }
diff --git a/src/dom/Lob.ts b/src/dom/Lob.ts
index 3c8f175..ecd3e38 100644
--- a/src/dom/Lob.ts
+++ b/src/dom/Lob.ts
@@ -1,4 +1,4 @@
-import { IonType } from "../Ion";
+import { IonType, IonTypes } from "../Ion";
 import {
   FromJsConstructor,
   FromJsConstructorBuilder,
@@ -33,5 +33,55 @@ export function Lob(ionType: IonType) {
     uInt8ArrayValue(): Uint8Array {
       return this;
     }
+
+    _ionEquals(
+      other: any,
+      options: {
+        epsilon?: number | null;
+        ignoreAnnotations?: boolean;
+        ignoreTimestampPrecision?: boolean;
+        onlyCompareIon?: boolean;
+      } = {
+        epsilon: null,
+        ignoreAnnotations: false,
+        ignoreTimestampPrecision: false,
+        onlyCompareIon: true,
+      }
+    ): boolean {
+      let isSupportedType: boolean = false;
+      let valueToCompare: any = null;
+      if (options.onlyCompareIon) {
+        // `compareOnlyIon` requires that the provided value be an ion.dom.Lob instance.
+        if (
+          other.getType() === IonTypes.CLOB ||
+          other.getType() === IonTypes.BLOB
+        ) {
+          isSupportedType = true;
+          valueToCompare = other.uInt8ArrayValue();
+        }
+      } else {
+        // We will consider other Lob-ish types
+        if (other instanceof global.Uint8Array) {
+          isSupportedType = true;
+          valueToCompare = other.valueOf();
+        }
+      }
+
+      if (!isSupportedType) {
+        return false;
+      }
+
+      let current = this.uInt8ArrayValue();
+      let expected = valueToCompare;
+      if (current.length !== expected.length) {
+        return false;
+      }
+      for (let i = 0; i < current.length; i++) {
+        if (current[i] !== expected[i]) {
+          return false;
+        }
+      }
+      return true;
+    }
   };
 }
diff --git a/src/dom/Null.ts b/src/dom/Null.ts
index ba0f3d6..0b0c57f 100644
--- a/src/dom/Null.ts
+++ b/src/dom/Null.ts
@@ -150,4 +150,40 @@ export class Null extends Value(Object, IonTypes.NULL, FromJsConstructor.NONE) {
     writer.setAnnotations(this.getAnnotations());
     writer.writeNull(this.getType());
   }
+
+  _ionEquals(
+    other: any,
+    options: {
+      epsilon?: number | null;
+      ignoreAnnotations?: boolean;
+      ignoreTimestampPrecision?: boolean;
+      onlyCompareIon?: boolean;
+    } = {
+      epsilon: null,
+      ignoreAnnotations: false,
+      ignoreTimestampPrecision: false,
+      onlyCompareIon: true,
+    }
+  ): boolean {
+    let isSupportedType: boolean = false;
+    let valueToCompare: any = null;
+    if (options.onlyCompareIon) {
+      // `compareOnlyIon` requires that the provided value be an ion.dom.Null instance.
+      if (other instanceof Null) {
+        isSupportedType = true;
+        valueToCompare = other;
+      }
+    } else {
+      // We will consider other Null-ish types
+      if (other === null && this._ionType.name === "null") {
+        return true;
+      }
+    }
+
+    if (!isSupportedType) {
+      return false;
+    }
+
+    return this._ionType.name === valueToCompare._ionType.name;
+  }
 }
diff --git a/src/dom/Sequence.ts b/src/dom/Sequence.ts
index 0929bd2..dd70a2c 100644
--- a/src/dom/Sequence.ts
+++ b/src/dom/Sequence.ts
@@ -74,5 +74,55 @@ export function Sequence(ionType: IonType) {
       }
       writer.stepOut();
     }
+
+    _ionEquals(
+      other: any,
+      options: {
+        epsilon?: number | null;
+        ignoreAnnotations?: boolean;
+        ignoreTimestampPrecision?: boolean;
+        onlyCompareIon?: boolean;
+      } = {
+        epsilon: null,
+        ignoreAnnotations: false,
+        ignoreTimestampPrecision: false,
+        onlyCompareIon: true,
+      }
+    ): boolean {
+      let isSupportedType: boolean = false;
+      let valueToCompare: any = null;
+      if (options.onlyCompareIon) {
+        // `compareOnlyIon` requires that the provided value be an ion.dom.Sequence instance.
+        if (
+          other.getType() === IonTypes.LIST ||
+          other.getType() === IonTypes.SEXP
+        ) {
+          isSupportedType = true;
+          valueToCompare = other.elements();
+        }
+      } else {
+        // We will consider other Sequence-ish types
+        if (other instanceof global.Array) {
+          isSupportedType = true;
+          valueToCompare = other;
+        }
+      }
+
+      if (!isSupportedType) {
+        return false;
+      }
+
+      let actualSequence = this.elements();
+      let expectedSequence = valueToCompare;
+      if (actualSequence.length !== expectedSequence.length) {
+        return false;
+      }
+      for (let i = 0; i < actualSequence.length; i++) {
+        if (!actualSequence[i].equals(expectedSequence[i], options)) {
+          return false;
+        }
+      }
+      return true;
+    }
   };
 }
diff --git a/src/dom/String.ts b/src/dom/String.ts
index f87fa33..1b56441 100644
--- a/src/dom/String.ts
+++ b/src/dom/String.ts
@@ -39,4 +39,45 @@ export class String extends Value(
     writer.setAnnotations(this.getAnnotations());
     writer.writeString(this.stringValue());
   }
+
+  _ionEquals(
+    other: any,
+    options: {
+      epsilon?: number | null;
+      ignoreAnnotations?: boolean;
+      ignoreTimestampPrecision?: boolean;
+      onlyCompareIon?: boolean;
+    } = {
+      epsilon: null,
+      ignoreAnnotations: false,
+      ignoreTimestampPrecision: false,
+      onlyCompareIon: true,
+    }
+  ): boolean {
+    let isSupportedType: boolean = false;
+    let valueToCompare: any = null;
+    if (options.onlyCompareIon) {
+      // `compareOnlyIon` requires that the provided value be an ion.dom.String instance.
+      if (other instanceof String) {
+        isSupportedType = true;
+        valueToCompare = other.stringValue();
+      }
+    } else {
+      // We will consider other String-ish types
+      if (typeof other === "string" || other instanceof global.String) {
+        isSupportedType = true;
+        valueToCompare = other.valueOf();
+      }
+    }
+
+    if (!isSupportedType) {
+      return false;
+    }
+
+    return this.compareValue(valueToCompare) === 0;
+  }
+
+  compareValue(expectedValue: string): number {
+    return this.stringValue().localeCompare(expectedValue);
+  }
 }
diff --git a/src/dom/Struct.ts b/src/dom/Struct.ts
index 6860a93..7eecfe8 100644
--- a/src/dom/Struct.ts
+++ b/src/dom/Struct.ts
@@ -150,4 +150,68 @@ export class Struct extends Value(
     ).map(([key, value]) => [key, Value.from(value)]);
     return new this(fields, annotations);
   }
+
+  _ionEquals(
+    other: any,
+    options: {
+      epsilon?: number | null;
+      ignoreAnnotations?: boolean;
+      ignoreTimestampPrecision?: boolean;
+      onlyCompareIon?: boolean;
+    } = {
+      epsilon: null,
+      ignoreAnnotations: false,
+      ignoreTimestampPrecision: false,
+      onlyCompareIon: true,
+    }
+  ): boolean {
+    let isSupportedType: boolean = false;
+    let valueToCompare: any = null;
+    if (options.onlyCompareIon) {
+      // `compareOnlyIon` requires that the provided value be an ion.dom.Struct instance.
+      if (other instanceof Struct) {
+        isSupportedType = true;
+        valueToCompare = other.fields();
+      }
+    } else {
+      // We will consider other Struct-ish types
+      if (typeof other === "object" || other instanceof global.Object) {
+        isSupportedType = true;
+        valueToCompare = Object.entries(other);
+      }
+    }
+
+    if (!isSupportedType) {
+      return false;
+    }
+
+    if (this.fields().length !== valueToCompare.length) {
+      return false;
+    }
+    let matchFound: boolean = true;
+    const paired: boolean[] = new Array<boolean>(valueToCompare.length);
+    for (let i: number = 0; matchFound && i < this.fields().length; i++) {
+      matchFound = false;
+      for (let j: number = 0; !matchFound && j < valueToCompare.length; j++) {
+        if (!paired[j]) {
+          const child = this.fields()[i];
+          const expectedChild = valueToCompare[j];
+          matchFound =
+            child[0] === expectedChild[0] &&
+            child[1].equals(expectedChild[1], options);
+          if (matchFound) {
+            paired[j] = true;
+          }
+        }
+      }
+    }
+    // set matchFound to the first pair that didn't find a matching field if any
+    for (let i: number = 0; i < paired.length; i++) {
+      if (!paired[i]) {
+        matchFound = false;
+        break;
+      }
+    }
+    return matchFound;
+  }
 }
diff --git a/src/dom/Symbol.ts b/src/dom/Symbol.ts
index a5f2c73..d663194 100644
--- a/src/dom/Symbol.ts
+++ b/src/dom/Symbol.ts
@@ -39,4 +39,45 @@ export class Symbol extends Value(String, IonTypes.SYMBOL, _fromJsConstructor) {
     writer.setAnnotations(this.getAnnotations());
     writer.writeSymbol(this.stringValue());
   }
+
+  _ionEquals(
+    other: any,
+    options: {
+      epsilon?: number | null;
+      ignoreAnnotations?: boolean;
+      ignoreTimestampPrecision?: boolean;
+      onlyCompareIon?: boolean;
+    } = {
+      epsilon: null,
+      ignoreAnnotations: false,
+      ignoreTimestampPrecision: false,
+      onlyCompareIon: true,
+    }
+  ): boolean {
+    let isSupportedType: boolean = false;
+    let valueToCompare: any = null;
+    if (options.onlyCompareIon) {
+      // `compareOnlyIon` requires that the provided value be an ion.dom.Symbol instance.
+      if (other instanceof Symbol) {
+        isSupportedType = true;
+        valueToCompare = other.stringValue();
+      }
+    } else {
+      // We will consider other Symbol-ish types
+      if (typeof other === "string" || other instanceof global.String) {
+        isSupportedType = true;
+        valueToCompare = other.valueOf();
+      }
+    }
+
+    if (!isSupportedType) {
+      return false;
+    }
+
+    return this.compareValue(valueToCompare) === 0;
+  }
+
+  compareValue(expectedValue: string): number {
+    return this.stringValue().localeCompare(expectedValue);
+  }
 }
diff --git a/src/dom/Timestamp.ts b/src/dom/Timestamp.ts
index 720f2ad..f6ce4ce 100644
--- a/src/dom/Timestamp.ts
+++ b/src/dom/Timestamp.ts
@@ -83,4 +83,51 @@ export class Timestamp extends Value(
     writer.setAnnotations(this.getAnnotations());
     writer.writeTimestamp(this.timestampValue());
   }
+
+  _ionEquals(
+    other: any,
+    options: {
+      epsilon?: number | null;
+      ignoreAnnotations?: boolean;
+      ignoreTimestampPrecision?: boolean;
+      onlyCompareIon?: boolean;
+    } = {
+      epsilon: null,
+      ignoreAnnotations: false,
+      ignoreTimestampPrecision: false,
+      onlyCompareIon: true,
+    }
+  ): boolean {
+    let isSupportedType: boolean = false;
+    let valueToCompare: any = null;
+    if (options.onlyCompareIon) {
+      // `compareOnlyIon` requires that the provided value be an ion.dom.Timestamp instance.
+      if (other instanceof Timestamp) {
+        isSupportedType = true;
+        valueToCompare = other.timestampValue();
+      }
+    } else {
+      // We will consider other Timestamp-ish types
+      if (other instanceof ion.Timestamp) {
+        // expectedValue is a non-DOM Timestamp
+        isSupportedType = true;
+        valueToCompare = other;
+      } else if (other instanceof global.Date) {
+        if (this.dateValue().getTime() === other.getTime()) {
+          return true;
+        } else {
+          return false;
+        }
+      }
+    }
+
+    if (!isSupportedType) {
+      return false;
+    }
+
+    if (options.ignoreTimestampPrecision) {
+      return this.timestampValue().compareTo(valueToCompare) === 0;
+    }
+    return this.timestampValue().equals(valueToCompare);
+  }
 }
diff --git a/src/dom/Value.ts b/src/dom/Value.ts
index 4e739e1..7443f8a 100644
--- a/src/dom/Value.ts
+++ b/src/dom/Value.ts
@@ -144,6 +144,42 @@ export interface Value {
    * indicating whether the Struct was modified. For all other types, throws an Error.
    */
   deleteField(name: string): boolean;
+
+  /**
+   * For checking equivalence of an Ion Values represented by dom.Value with the other value represented
+   * by dom.Value or an JS object.
+   *
+   * Strict equivalence refers to Ion data model equivalence
+   * as defined in Ion Equivalence[1] and by Ion Specification[2]
+   *
+   * Structural or non-strict equivalence follows the same rules as strict equivalence,
+   * except that
+   *
+   *   1. Annotations are not considered, and
+   *   2. Timestamps that represent the same instant in time are always
+   *      considered equivalent.
+   *
+   * [1] https://www.javadoc.io/doc/com.amazon.ion/ion-java/latest/com/amazon/ion/util/Equivalence.html
+   * [2] http://amzn.github.io/ion-docs/docs/spec.html
+   *
+   * @param other                       other Ion Value to be compared with this Ion Value.
+   * @param options                     options provided for equivalence as below
+   *        epsilon                     used by Float for an equality with given epsilon precision. (Default: null)
+   *        ignoreAnnotations           specifies whether to ignore annotations or not for equality. (Default: false)
+   *        ignoreTimestampPrecision    specifies whether to ignore timestamp local offset and precision
+   *                                    or not for equality. (Default: false)
+   *        onlyCompareIon:             specifies if only dom.Values should be considered for equality
+   *                                    or can be JS object as well. (Default: true)
+   */
+  equals(
+    other: any,
+    options?: {
+      epsilon?: number | null;
+      ignoreAnnotations?: boolean;
+      ignoreTimestampPrecision?: boolean;
+      onlyCompareIon?: boolean;
+    }
+  ): boolean;
 }
 
 /**
@@ -309,6 +345,68 @@ export function Value<Clazz extends Constructor>(
       this._unsupportedOperation("deleteField");
     }
 
+    /**
+     * Helper method to be implemented by each subclass of dom.Value with individual equivalence logic.
+     */
+    // Class expressions (like this mixin) cannot have private or protected methods.
+    _ionEquals(
+      other: any,
+      options: {
+        epsilon?: number | null;
+        ignoreAnnotations?: boolean;
+        ignoreTimestampPrecision?: boolean;
+        onlyCompareIon?: boolean;
+      } = {
+        epsilon: null,
+        ignoreAnnotations: false,
+        ignoreTimestampPrecision: false,
+        onlyCompareIon: true,
+      }
+    ): boolean {
+      this._unsupportedOperation("_ionEquals");
+    }
+
+    /**
+     * Implementation of the dom.Value interface method equals()
+     */
+    equals(
+      other: any,
+      options: {
+        epsilon?: number | null;
+        ignoreAnnotations?: boolean;
+        ignoreTimestampPrecision?: boolean;
+        onlyCompareIon?: boolean;
+      } = {
+        epsilon: null,
+        ignoreAnnotations: false,
+        ignoreTimestampPrecision: false,
+        onlyCompareIon: true,
+      }
+    ): boolean {
+      if (options.onlyCompareIon === false) {
+        options.ignoreAnnotations = true;
+        options.ignoreTimestampPrecision = true;
+      }
+      if (!options.ignoreAnnotations) {
+        if (!(other instanceof Value)) {
+          return false;
+        }
+        let actualAnnotations = this.getAnnotations();
+        let expectedAnnotations = (other as Value).getAnnotations();
+        if (actualAnnotations.length !== expectedAnnotations.length) {
+          return false;
+        }
+        for (let i = 0; i < actualAnnotations.length; i++) {
+          if (
+            actualAnnotations[i].localeCompare(expectedAnnotations[i]) !== 0
+          ) {
+            return false;
+          }
+        }
+      }
+      return this._ionEquals(other, options);
+    }
+
     // Returns the IonType associated with a particular dom.Value subclass. Useful for testing.
     static _getIonType(): IonType {
       return ionType;
@@ -382,6 +480,22 @@ export namespace Value {
     }
     return JsValueConversion._ionValueFromJsValue(value, annotations);
   }
+
+  // a namespace with pre-filled option values for STRICT and NON STRICT(RELAXED) equality check
+  export namespace Equality {
+    export const STRICT = {
+      ignoreAnnotations: false,
+      ignoreTimestampPrecision: false,
+      onlyCompareIon: false,
+      epsilon: null,
+    };
+    export const RELAXED = {
+      ignoreAnnotations: true,
+      ignoreTimestampPrecision: true,
+      onlyCompareIon: true,
+      epsilon: null,
+    };
+  }
 }
 
 // When testing whether a given object is an `instanceof Value`, this function


Test patch:
diff --git a/test/dom/equivalence.ts b/test/dom/equivalence.ts
new file mode 100644
index 0000000..08f530f
--- /dev/null
+++ b/test/dom/equivalence.ts
@@ -0,0 +1,347 @@
+import { assert } from "chai";
+import { Value, load } from "../../src/dom";
+import { Decimal } from "../../src/IonDecimal";
+
+/**
+ * This file contains 'equals()' tests for each Ion data type:
+ * - Demonstrates equivalence of dom.Value with strict and non-strict(relaxed) modes.
+ * - Also shows equivalence with epsilon precision for Float values.
+ * - Demonstrates equivalence with JS values
+ *
+ * See the class-level documentation for more details on equals()
+ */
+
+describe("Equivalence", () => {
+  it("equals() for Null", () => {
+    let nullValue1: Value = load("null.blob")!;
+    let nullValue2: Value = load("null.blob")!;
+    let nullValue3: Value = load("null")!;
+    let nullValue4: Value = load("null.null")!;
+    let nullValue5: Value = load('"null"')!;
+    assert.isTrue(nullValue1.equals(nullValue2));
+    assert.isFalse(nullValue1.equals(nullValue3));
+    assert.isTrue(nullValue3.equals(nullValue4));
+    assert.isFalse(nullValue3.equals(nullValue5));
+    // Equivalence between JS Value and Ion DOM Value
+    assert.isTrue(nullValue3.equals(null, { onlyCompareIon: false }));
+    assert.isFalse(nullValue3.equals(null));
+    assert.isFalse(nullValue1.equals(null, { onlyCompareIon: false }));
+  });
+
+  it("equals() for Boolean", () => {
+    let bool1: Value = load("foo::false")!;
+    let bool2: Value = load("foo::false")!;
+    let bool3: Value = load("false")!;
+    let bool4: Value = load("true")!;
+    assert.isTrue(bool1.equals(bool2));
+    // annotations should match for strict comparison
+    assert.isFalse(bool1.equals(bool3));
+    assert.isTrue(bool1.equals(bool3, Value.Equality.RELAXED));
+    assert.isFalse(bool1.equals(bool4));
+    // Equivalence between JS Value and Ion DOM Value
+    assert.isTrue(bool1.equals(false, { onlyCompareIon: false }));
+    assert.isFalse(bool1.equals(false));
+    assert.isFalse(bool1.equals(true, { onlyCompareIon: false }));
+    assert.isTrue(bool1.equals(new Boolean(false), { onlyCompareIon: false }));
+    assert.isFalse(bool1.equals(new Boolean(true), { onlyCompareIon: false }));
+  });
+
+  it("equals() for Integer", () => {
+    let int1: Value = load("foo::bar::7")!;
+    let int2: Value = load("foo::bar::7")!;
+    let int3: Value = load("7")!;
+    let int4: Value = load("-10")!;
+    assert.isTrue(int1.equals(int2));
+    // annotations should match for strict comparison
+    assert.isFalse(int1.equals(int3));
+    assert.isTrue(int1.equals(int3, Value.Equality.RELAXED));
+    assert.isFalse(int1.equals(int4));
+    // Equivalence between JS Value and Ion DOM Value
+    assert.isTrue(int1.equals(7, { onlyCompareIon: false }));
+    assert.isFalse(int1.equals(7));
+    assert.isFalse(int1.equals(10, { onlyCompareIon: false }));
+    assert.isTrue(int1.equals(new Number(7), { onlyCompareIon: false }));
+    assert.isFalse(int1.equals(new Number(10), { onlyCompareIon: false }));
+  });
+
+  it("equals() for Float", () => {
+    let float1: Value = load("baz::qux::15e-1")!;
+    let float2: Value = load("baz::qux::15e-1")!;
+    let float3: Value = load("15e-1")!;
+    let float4: Value = load("1.5")!;
+    let float5: Value = load("12e-1")!;
+
+    assert.isTrue(float1.equals(float2));
+    // annotations should match for strict comparison
+    assert.isFalse(float1.equals(float3));
+    assert.isTrue(float1.equals(float3, Value.Equality.RELAXED));
+    // Decimal and Float values will not be equivalent
+    assert.isFalse(float1.equals(float4));
+    // both values should be at least equivalent by epsilon value
+    assert.isTrue(float3.equals(float5, { epsilon: 0.5 }));
+    assert.isFalse(float3.equals(float5, { epsilon: 0.2 }));
+    // Equivalence between JS Value and Ion DOM Value
+    assert.isTrue(float1.equals(1.5, { onlyCompareIon: false }));
+    assert.isFalse(float1.equals(1.5));
+    assert.isFalse(float1.equals(1.2, { onlyCompareIon: false }));
+    assert.isTrue(float1.equals(1.2, { onlyCompareIon: false, epsilon: 0.5 }));
+    assert.isTrue(float1.equals(new Number(1.5), { onlyCompareIon: false }));
+    assert.isFalse(float1.equals(new Number(1.2), { onlyCompareIon: false }));
+    assert.isTrue(
+      float1.equals(new Number(1.2), { onlyCompareIon: false, epsilon: 0.5 })
+    );
+  });
+
+  it("equals() for Decimal", () => {
+    let decimal1: Value = load("101.5")!;
+    let decimal2: Value = load("101.5")!;
+    let decimal3: Value = load("101")!;
+
+    assert.isTrue(decimal1.equals(decimal2));
+    assert.isFalse(decimal1.equals(decimal3));
+    assert.isFalse(decimal3.equals(decimal1));
+    // Equivalence between JS Value and Ion DOM Value
+    assert.isTrue(
+      decimal1.equals(new Decimal("101.5"), { onlyCompareIon: false })
+    );
+    assert.isFalse(
+      decimal1.equals(new Decimal("105.8"), { onlyCompareIon: false })
+    );
+  });
+
+  it("equals() for Timestamp", () => {
+    let timestamp1: Value = load("DOB::2020-01-16T20:15:54.066Z")!;
+    let timestamp2: Value = load("DOB::2020-01-16T20:15:54.066Z")!;
+    let timestamp3: Value = load("DOB::2001T")!;
+    let timestamp4: Value = load("DOB::2001-01-01T")!;
+
+    assert.isTrue(timestamp1.equals(timestamp2));
+    // In strict mode the precision and local offsets are also compared
+    assert.isFalse(timestamp1.equals(timestamp3));
+    // Non strict mode precision and local offset are ignored along with annotations
+    assert.isTrue(timestamp3.equals(timestamp4, Value.Equality.RELAXED));
+    // Equivalence between JS Value and Ion DOM Value
+    assert.isTrue(
+      timestamp1.equals(new Date("2020-01-16T20:15:54.066Z"), {
+        onlyCompareIon: false,
+      })
+    );
+    assert.isFalse(
+      timestamp1.equals(new Date("2020-02-16T20:15:54.066Z"), {
+        onlyCompareIon: false,
+      })
+    );
+  });
+
+  it("equals() for Symbol", () => {
+    let symbol1: Value = load('"Saturn"')!;
+    let symbol2: Value = load('"Saturn"')!;
+    let symbol3: Value = load('"Jupiter"')!;
+
+    assert.isTrue(symbol1.equals(symbol2));
+    assert.isFalse(symbol1.equals(symbol3));
+    // Equivalence between JS Value and Ion DOM Value
+    assert.isTrue(symbol1.equals("Saturn", { onlyCompareIon: false }));
+    assert.isTrue(
+      symbol1.equals(new String("Saturn"), { onlyCompareIon: false })
+    );
+    assert.isFalse(
+      symbol1.equals(new String("Jupiter"), { onlyCompareIon: false })
+    );
+    assert.isFalse(symbol1.equals("Jupiter", { onlyCompareIon: false }));
+    assert.isFalse(symbol1.equals(new String("Saturn")));
+    assert.isFalse(symbol1.equals("Saturn"));
+  });
+
+  it("equals() for String", () => {
+    let string1: Value = load('"Saturn"')!;
+    let string2: Value = load('"Saturn"')!;
+    let string3: Value = load('"Jupiter"')!;
+
+    assert.isTrue(string1.equals(string2));
+    assert.isFalse(string1.equals(string3));
+    // Equivalence between JS Value and Ion DOM Value
+    assert.isTrue(string1.equals("Saturn", { onlyCompareIon: false }));
+    assert.isTrue(
+      string1.equals(new String("Saturn"), { onlyCompareIon: false })
+    );
+    assert.isFalse(
+      string1.equals(new String("Jupiter"), { onlyCompareIon: false })
+    );
+    assert.isFalse(string1.equals("Jupiter", { onlyCompareIon: false }));
+    assert.isFalse(string1.equals(new String("Saturn")));
+    assert.isFalse(string1.equals("Saturn"));
+  });
+
+  it("equals() for Clob", () => {
+    let clob1: Value = load('month::{{"February"}}')!;
+    let clob2: Value = load('month::{{"February"}}')!;
+    let clob3: Value = load('month::{{"January"}}')!;
+    let clob4: Value = load('{{"Hello"}}')!;
+
+    assert.isTrue(clob1.equals(clob2));
+    assert.isFalse(clob1.equals(clob3));
+    // Equivalence between JS Value and Ion DOM Value
+    assert.isTrue(
+      clob4.equals(new Uint8Array([72, 101, 108, 108, 111]), {
+        onlyCompareIon: false,
+      })
+    );
+    assert.isFalse(
+      clob4.equals(new Uint8Array([72, 101, 108, 108]), {
+        onlyCompareIon: false,
+      })
+    );
+  });
+
+  it("equals() for Blob", () => {
+    let blob1: Value = load("quote::{{VG8gaW5maW5pdHkuLi4gYW5kIGJleW9uZCE=}}")!;
+    let blob2: Value = load("quote::{{VG8gaW5maW5pdHkuLi4gYW5kIGJleW9uZCE=}}")!;
+    let blob3: Value = load("quote::{{dHdvIHBhZGRpbmcgY2hhcmFjdGVycw==}}")!;
+    let blob4: Value = load("{{VG8gaW5maW5pdHkuLi4gYW5kIGJleW9uZCE=}}")!;
+
+    assert.isTrue(blob1.equals(blob2));
+    assert.isFalse(blob1.equals(blob3));
+    assert.isTrue(blob1.equals(blob4, Value.Equality.RELAXED));
+  });
+
+  it("equals() for List", () => {
+    let list1: Value = load('planets::["Mercury", "Venus", "Earth", "Mars"]')!;
+    let list2: Value = load('planets::["Mercury", "Venus", "Earth", "Mars"]')!;
+    let list3: Value = load('planets::["Mercury", "Venus"]')!;
+    let list4: Value = load(
+      'planets::["Mercury", "Venus", "Earth", "Jupiter"]'
+    )!;
+
+    assert.isTrue(list1.equals(list2));
+    assert.isFalse(list1.equals(list3));
+    assert.isFalse(list3.equals(list1));
+    assert.isFalse(list1.equals(list4));
+    assert.isFalse(list4.equals(list1));
+    // Equivalence between JS Value and Ion DOM Value
+    assert.isTrue(
+      list1.equals(["Mercury", "Venus", "Earth", "Mars"], {
+        onlyCompareIon: false,
+      })
+    );
+    assert.isFalse(
+      list1.equals(["Mercury", "Venus", "Earth"], { onlyCompareIon: false })
+    );
+  });
+
+  it("equals() for SExpression", () => {
+    let sexp1: Value = load('planets::("Mercury" "Venus" "Earth" "Mars")')!;
+    let sexp2: Value = load('planets::("Mercury" "Venus" "Earth" "Mars")')!;
+    let sexp3: Value = load('planets::("Mercury" "Venus" "Earth")')!;
+    let sexp4: Value = load('planets::("Mercury" "Venus" "Earth" "Jupiter")')!;
+
+    assert.isTrue(sexp1.equals(sexp2));
+    assert.isFalse(sexp1.equals(sexp3));
+    assert.isFalse(sexp3.equals(sexp1));
+    assert.isFalse(sexp1.equals(sexp4));
+    assert.isFalse(sexp4.equals(sexp1));
+  });
+
+  it("equals() for Struct", () => {
+    let struct1: Value = load(
+      "foo::bar::{" +
+        "name: {" +
+        'first: "John", ' +
+        'middle: "Jacob", ' +
+        'last: "Jingleheimer-Schmidt",' +
+        "}," +
+        "age: 41" +
+        "}"
+    )!;
+    let struct2: Value = load(
+      "foo::bar::{" +
+        "name: {" +
+        'first: "John", ' +
+        'middle: "Jacob", ' +
+        'last: "Jingleheimer-Schmidt",' +
+        "}," +
+        "age: 41" +
+        "}"
+    )!;
+    let struct3: Value = load(
+      "foo::bar::{" +
+        "name: {" +
+        'first: "Jessica", ' +
+        'middle: "Jacob", ' +
+        'last: "Jingleheimer-Schmidt",' +
+        "}," +
+        "age: 41" +
+        "}"
+    )!;
+    let struct4: Value = load(
+      "foo::bar::{" +
+        "name: {" +
+        'first: "John", ' +
+        'middle: "Jacob", ' +
+        'last: "Jingleheimer-Schmidt",' +
+        "}," +
+        "}"
+    )!;
+    let struct5: Value = load(
+      "{" +
+        "name: {" +
+        'first: "John", ' +
+        'middle: "Jacob", ' +
+        'last: "Jingleheimer-Schmidt",' +
+        "}," +
+        "}"
+    )!;
+    assert.isTrue(struct1.equals(struct2));
+    assert.isFalse(struct1.equals(struct3));
+    assert.isFalse(struct3.equals(struct1));
+    assert.isFalse(struct1.equals(struct4));
+    assert.isFalse(struct4.equals(struct1));
+    // annotations should match for strict comparison
+    assert.isTrue(struct4.equals(struct5, Value.Equality.RELAXED));
+    // Equivalence between JS Value and Ion DOM Value
+    assert.isTrue(
+      struct1.equals(
+        {
+          name: {
+            first: "John",
+            middle: "Jacob",
+            last: "Jingleheimer-Schmidt",
+          },
+          age: 41,
+        },
+        { onlyCompareIon: false }
+      )
+    );
+    assert.isFalse(
+      struct1.equals(
+        {
+          name: {
+            first: "John",
+            middle: "Jacob",
+            last: "Jingleheimer-Schmidt",
+          },
+        },
+        { onlyCompareIon: false }
+      )
+    );
+  });
+  it("equals() for Struct inside List", () => {
+    let value: Value = load(
+      '[{ foo: 7, bar: true, baz: 98.6, qux: "Hello" }]'
+    )!;
+    assert.isTrue(
+      value.equals([{ foo: 7, bar: true, baz: 98.6, qux: "Hello" }], {
+        onlyCompareIon: false,
+      })
+    );
+  });
+  it("equals() for List inside Struct", () => {
+    let value: Value = load("{ foo: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] }")!;
+    assert.isTrue(
+      value.equals(
+        { foo: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] },
+        { onlyCompareIon: false }
+      )
+    );
+  });
+});