DEV Community

Vera Yang for BeGoodTool.com

Posted on

Generating unique random numbers is easy — swapping min and max broke my own validation

I put together a small number picker for an office raffle: type a minimum, type a maximum, hit generate, optionally pull several numbers with no repeats so you can draw multiple winners at once. It felt like a five-minute job. It mostly was — right up until I typed the range in backwards and the tool told me a perfectly valid request was impossible.

Letting min and max be typed in either order

The core random-pick function doesn't trust that the "minimum" field actually holds the smaller number:

function random() {
  let real_min = Math.min(min.value, max.value);
  let real_max = Math.max(min.value, max.value);
  let real_res =
    Math.floor(Math.random() * (real_max - real_min + 1)) + real_min;
  return real_res;
}
Enter fullscreen mode Exit fullscreen mode

Math.min/Math.max normalize the two inputs before doing anything else, so if you type 99 into "min" and 1 into "max," it just works — no error telling you to swap the fields. The actual random pick is the standard inclusive-integer-range formula: Math.random() gives you [0, 1), multiplying by (range + 1) and flooring spreads that across every integer in the range with equal probability, then you shift up by the minimum. At the range sizes this tool deals with (double-digit or triple-digit spans), there's no meaningful Math.random() bias to worry about — that only becomes a real concern at ranges in the billions, where floating-point precision starts running out of distinct multiples.

The validation check that forgot about the swap

Here's where it got interesting. The "generate multiple, no duplicates" path validates the range before generating anything:

function getRandom() {
  let real_res = [];
  if (max.value - min.value + 1 < multiple.value) {
    return alert(t("random.error1"));
  }
  if (isMultiple.value) {
    if (multiple.value * 1 > 100) multiple.value = 100;
    for (let i = 0; i < multiple.value; i++) {
      let now = random();
      if (!real_res.includes(now)) {
        real_res.push(now);
      } else {
        i--;
      }
    }
  }
  ...
Enter fullscreen mode Exit fullscreen mode

Look closely at that first if. It computes max.value - min.value + 1 — the raw field values, with no Math.min/Math.max normalization. random() happily tolerates a swapped min/max, but this check doesn't. Type min=99, max=1, ask for 5 unique numbers, and the check computes 1 - 99 + 1 = -97, which is less than 5, so it rejects a request that's actually completely fine — there are 99 valid numbers to draw from. The tool is inconsistent with itself: it's forgiving about swapped input everywhere else, then strict about it in exactly the one place where being strict produces a wrong answer.

Retry until it's unique, not shuffle-and-slice

The no-duplicates mode itself is worth a second look, because there are two very different ways to implement "give me N unique numbers from a range," and this uses the simpler-sounding one:

for (let i = 0; i < multiple.value; i++) {
  let now = random();
  if (!real_res.includes(now)) {
    real_res.push(now);
  } else {
    i--;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is rejection sampling: draw a number, check if it's already in the result array, and if it is, decrement the loop counter so the same slot gets tried again. It's unbiased — every still-available number is equally likely on each draw — and it needs zero extra memory beyond the result array itself.

The alternative would be shuffle-and-slice: build an array of the entire range, Fisher–Yates shuffle it, and take the first N. That's guaranteed to finish in time proportional to the range size no matter what, but it has to allocate an array covering the whole range even if you only want 3 numbers out of it.

Retry-until-unique flips that trade-off: cheap when N is small relative to the range, but it gets worse the closer N gets to the range size. Near the end, most of the range is already taken, so a growing fraction of draws come back as duplicates and get thrown away — the classic coupon-collector problem. On top of that, each real_res.includes(now) check is a linear scan, so as the result array grows the checks themselves get slower too. This tool caps multiple at 100, so in practice you'd have to set a very tight range (like 1–100 while asking for 100 numbers) to actually feel the slowdown — but it's the kind of thing that would matter a lot if someone reused this loop for a bigger range without reusing the cap.

Limitations

A few things worth knowing if you actually use this:

  • The min/max-swap validation bug above is real — if your "no duplicates" request gets rejected as impossible, try swapping which number you put in which field before assuming your range is actually too small.
  • That multiple.value * 1 > 100 clamp happens after the range check already ran with whatever number you originally typed, and it silently caps your request to 100 with no message. Ask for 500 unique numbers from a range of 10,000 and you'll quietly get 100 back, not 500.
  • Math.random() is a pseudorandom generator, not a cryptographically secure one. That's completely fine for raffles, games, and sampling, but it's not the right tool for generating anything security-sensitive, like password seeds or real-money draws.

I cleaned up the version I built for the raffle into a small free tool: Random Number Generator (RNG). No sign-up, works for any range you throw at it — just watch which field you put the bigger number in.


Available in other languages

Top comments (0)