fix: `arrayUnique` is O(n²) and reports any array containing `NaN` as non-unique

Author: itamar-gerasyCreated Aug 25, 2026Updated Sep 12, 2026
Labelstype: fixstatus: needs triage

arrayUnique decides uniqueness using Array.prototype.indexOf inside Array.prototype.filter. This has O(n²) worst-case time complexity for arrays of distinct values.

It also reports any array containing NaN as non-unique, including arrays in which NaN occurs only once.

Both behaviors originate from the same implementation in src/decorator/array/ArrayUnique.ts:

typescript
export function arrayUnique(array: unknown[], identifier?: ArrayUniqueIdentifier): boolean {
  if (!Array.isArray(array)) return false;

  if (identifier) {
    array = array.map(o => (o != null ? identifier(o) : o));
  }

  const uniqueItems = array.filter((a, b, c) => c.indexOf(a) === b);
  return array.length === uniqueItems.length;
}

Problem 1 — quadratic worst-case cost

filter visits every element, while indexOf scans from the beginning of the array for each element.

For an array containing distinct values, the amount of work grows quadratically with the array length. On DTOs validated frequently along a hot path, this cost can dominate validation time.

Problem 2 — arrays containing NaN are always reported as non-unique

indexOf uses strict equality, and NaN !== NaN. Consequently, indexOf(NaN) always returns -1.

The NaN element is filtered out, the resulting array length does not match the original length, and validation fails even if NaN occurs only once:

javascript
const { arrayUnique } = require('class-validator');

arrayUnique([NaN]); // false — expected true
arrayUnique([1, NaN, 2]); // false — expected true
arrayUnique([NaN, NaN]); // false — expected false

Minimal performance reproduction

This reproduction has no dependencies beyond class-validator:

javascript
const { randomUUID } = require('crypto');
const { arrayUnique } = require('class-validator');

const setBased = (array, identifier) => {
  if (!Array.isArray(array)) return false;

  const values = identifier
    ? array.map(value => (value != null ? identifier(value) : value))
    : array;

  return new Set(values).size === values.length;
};

const time = (fn, array, iterations) => {
  fn(array);
  fn(array);

  const started = process.hrtime.bigint();

  for (let index = 0; index < iterations; index++) {
    fn(array);
  }

  return Number(process.hrtime.bigint() - started) / 1e6 / iterations;
};

for (const length of [100, 250, 500, 1000, 2000, 5000]) {
  const array = Array.from({ length }, () => randomUUID());
  const iterations = length > 1000 ? 20 : 200;

  console.log(
    length,
    time(arrayUnique, array, iterations).toFixed(3),
    time(setBased, array, iterations).toFixed(3)
  );
}

Results on Node.js v24.13.1 with [email protected] and arrays of UUID strings:

Array length Current (ms) Set-based (ms) Speedup
100 0.068 0.004 18×
250 0.457 0.010 45×
500 1.637 0.009 180×
1000 4.233 0.019 219×
2000 26.822 0.058 465×
5000 102.850 0.184 559×

In this benchmark, increasing the array length by 50 times increased the current implementation's execution time by approximately 1,500 times. The Set-based implementation scales approximately linearly.

These are local microbenchmark results and may vary between environments.

Expected behavior

  • Uniqueness is determined in O(n) expected time rather than O(n²) worst-case time.
  • A single NaN, or one NaN among otherwise distinct values, is accepted.
  • Multiple NaN values are rejected as duplicates.
  • Object comparison remains reference-based.
  • Identifier functions continue to determine the compared values.
  • Sparse array holes follow normal Set iteration semantics and are treated as undefined.

Proposed fix

typescript
export function arrayUnique(array: unknown[], identifier?: ArrayUniqueIdentifier): boolean {
  if (!Array.isArray(array)) return false;

  const values = identifier ? array.map(o => (o != null ? identifier(o) : o)) : array;
  return new Set(values).size === values.length;
}

Set uses SameValueZero equality:

  • Object comparison remains reference-based.
  • +0 and -0 remain equal.
  • null and undefined remain distinct.
  • NaN is equal to itself for duplicate detection.

This changes temporary allocation characteristics because a Set can use more memory than the filtered array. The proposed change intentionally accepts that tradeoff in exchange for substantially better worst-case runtime.

Behavioral comparison

Input Current Set-based Match
[] true true Yes
[1, 2, 3] true true Yes
[1, 1] false false Yes
['a', 'a'] false false Yes
[null, null] false false Yes
[undefined, undefined] false false Yes
[null, undefined] true true Yes
[{ a: 1 }, { a: 1 }] using distinct references true true Yes
[object, object] using the same reference false false Yes
[+0, -0] false false Yes
[NaN, NaN] false false Yes
[NaN] false true Intentional change
[1, NaN, 2] false true Intentional change
[, 1] false true Intentional change
[, , 1] false false Yes
[undefined, , 1] false false Yes
Non-array values false false Yes
Identifier producing [1, 1, 2] false false Yes

Sparse arrays are uncommon in typical DTO data, particularly because JSON does not preserve array holes distinctly.

Real-world impact

I found this while profiling a service that validates a cached DTO for every incoming message. The DTO contains an array of several hundred UUID strings decorated with @ArrayUnique(), and the object is re-materialized for each message.

arrayUnique accounted for approximately 52% of the process's main-thread CPU in the captured profile. With an array of approximately 1,250 elements in the DTO, it became the dominant cost in that service. Removing validation for that array increased observed throughput by approximately 2.6 times.

The performance cost is difficult to anticipate at the call site because @ArrayUnique() does not indicate that its current implementation has quadratic worst-case complexity.

Environment

I have a fork containing the proposed implementation and regression tests and can open a pull request against this issue.

Source: typestack/class-validator