DEV Community

Cover image for Building a Reliable Trade Calculator with TypeScript and BigInt
Rohan Arora
Rohan Arora

Posted on

Building a Reliable Trade Calculator with TypeScript and BigInt

Imagine comparing two offers worth 98 and 100 units. Your calculator calls that fair.

Now change the first offer to 97.999999. Both comparisons can display a rounded difference of 2.00%, but only one falls inside an inclusive 2% fairness band.

That small boundary captures a lot of what makes a calculator interesting to build. The interface needs to explain a decision that depends on precise arithmetic, complete inputs, and a particular version of the data.

I built Adopt Me Value Calculator to compare bundles of virtual pets and items. The application uses Next.js, React, and TypeScript, with Payload CMS and PostgreSQL for editorial data. Visitors calculate offers against published snapshots and keep saved trades in their browser.

This post focuses on three decisions behind that design: how values are represented, when a result is allowed, and what happens when prices change. The examples below use illustrative values and simplified versions of the calculation helpers.

1. Keep decimal quotes exact from the moment they enter the system

The familiar JavaScript example is easy to reproduce:

0.1 + 0.2 === 0.3; // false
Enter fullscreen mode Exit fullscreen mode

A formatted total can hide that difference. A comparison near a decision boundary still needs a defined arithmetic rule.

The calculator accepts quotes with up to six decimal places. It stores those quotes as decimal strings and converts them into scaled integers for calculation:

const SCALE = 1_000_000n;
const MAX_QUOTE = 1_000_000_000_000n * SCALE;

function parseValue(input: string): bigint {
  if (
    typeof input !== "string" ||
    !/^(?:0|[1-9][0-9]{0,12})(?:\.[0-9]{1,6})?$/.test(input)
  ) {
    throw new Error("Expected a non-negative decimal with up to 6 places");
  }

  const [whole, fraction = ""] = input.split(".");
  const value =
    BigInt(whole) * SCALE +
    BigInt(fraction.padEnd(6, "0"));

  if (value > MAX_QUOTE) {
    throw new Error("Quote exceeds the supported maximum");
  }

  return value;
}

parseValue("0.1") + parseValue("0.2") === parseValue("0.3"); // true
Enter fullscreen mode Exit fullscreen mode

Here, "12.345678" becomes 12_345_678n. An offer total is the sum of each scaled quote multiplied by its validated integer quantity.

BigInt represents integer values; the decimal behavior comes from our chosen scale. That choice also defines the smallest supported increment: one millionth of a unit.

The parser rejects scientific notation, negative values, extra precision, and out-of-range quotes. Those are explicit input rules, so an importer cannot silently round a seventh decimal place away.

Keep decimal strings at JSON boundaries and convert them directly into scaled integers. Passing through Number first would lose the benefit of preserving the original decimal text. Persisted evaluation totals use strings too, since ordinary JSON.stringify cannot serialize a BigInt value directly.

2. Make the fairness decision before rounding the display

The site's published methodology defines the gap as:

gap = abs(receive - give) / max(give, receive)
Enter fullscreen mode Exit fullscreen mode

An offer is fair when that gap is at most 2%. This is a product policy, so the denominator and inclusive boundary both need to be deliberate.

Two percent is 200 basis points. Cross multiplication lets us compare the gap without division:

abs(receive - give) × 10,000 <= max(give, receive) × 200
Enter fullscreen mode Exit fullscreen mode

The helper becomes:

type Verdict = "win" | "fair" | "lose";

// Call only after both offers and every required quote are validated.
function classifyCompleteTrade(
  give: bigint,
  receive: bigint,
  fairnessBasisPoints = 200n,
): Verdict {
  const difference = receive - give;
  const absoluteDifference = difference < 0n ? -difference : difference;
  const larger = give > receive ? give : receive;

  if (
    larger === 0n ||
    absoluteDifference * 10_000n <= larger * fairnessBasisPoints
  ) {
    return "fair";
  }

  return difference > 0n ? "win" : "lose";
}
Enter fullscreen mode Exit fullscreen mode

For 98 versus 100, the gap is exactly 2%. For 97.999999 versus 100, it is 2.000001%. The second comparison receives a Win from the perspective of the person receiving 100, even if the displayed percentage rounds to 2.00%.

That gives the interface an extra responsibility: explain close results. A rounded percentage should not make an exact verdict look arbitrary. More display precision near the boundary, or a short explanation of the cutoff, can help.

The full engine reads the fairness threshold from the selected release. The helper above accepts it as an argument so the policy stays separate from the arithmetic.

3. Give unknown prices their own state

Consider an offer containing two items. One has a reviewed quote of 20. The other has no quote.

Code such as total += quote ?? 0n would produce a total of 20 and let the application continue toward a verdict. A user could mistake a known subtotal for the value of the whole offer.

The data model distinguishes reviewed values from unpriced entries. A simplified TypeScript representation is:

type Quote =
  | { status: "reviewed"; value: string }
  | { status: "unpriced"; value: null };
Enter fullscreen mode Exit fullscreen mode

The evaluation also has a status separate from its Win/Fair/Lose result:

  • empty: neither offer contains items.
  • one-sided: an offer is still missing.
  • incomplete: at least one selected variant has no reviewed quote.
  • invalid: validation failed, including a release mismatch.
  • complete: both offers are present and every required quote is available.

Only a complete evaluation can produce a verdict. An incomplete evaluation can show known subtotals and identify missing quotes, while leaving the comparison undecided.

A reviewed zero remains a valid value. Two populated offers whose reviewed totals are both zero can be fair; two empty offers remain an empty state. The totals alone cannot tell you which situation exists.

These rules make uncertainty visible all the way from the imported data to the interface.

4. Identify the exact variant and value system

In Adopt Me, a pet can have Normal, Neon, or Mega status, along with Fly and Ride combinations. The item name alone does not identify the selected asset precisely enough.

The engine looks up quotes using a variant identifier and a value system. Quantities refer to that exact variant.

The distinction matters because an interface should not invent a missing variant quote by multiplying another quote. Any source-specific transformations belong in the ingestion process, where their rules and provenance can be checked. The calculation engine consumes the resulting explicit records.

Value systems need the same care. RP and Shark quotes are maintained separately, so a total from one cannot be compared directly with a total from the other. For releases using a shared Shark-to-Frost benchmark conversion, the engine keeps the comparison in the underlying units and converts the display afterward. Direct Frost quote releases use their own recorded values.

Demand is separate context as well. Showing a demand label does not silently add an undocumented premium to the arithmetic.

5. Save the data release alongside the trade

A saved offer needs more context than its item list.

Suppose a comparison is fair today. Next week, new quotes make the same bundle uneven. Opening the saved trade should make it clear which data the original result used.

Each trade therefore records a releaseId, its value system, and both offers. Each published snapshot contains the item and quote records, benchmarks, and settings needed for evaluation. Saving a trade also stores its snapshot and original evaluation in the browser.

The engine rejects a trade whose release identifier does not match the supplied snapshot. Recalculating with the latest release is an explicit action.

Publication follows an ordered process:

  1. Validate the candidate and compare it with the active release.
  2. Check that the active release and reviewed draft have not changed.
  3. Write a new release in a staging directory.
  4. Verify its checksum and record the publication audit.
  5. Move the completed release into place, then atomically replace the active manifest.

The filesystem operations run under a publication lock. On this single-host design, readers can resolve a complete old or new release while publication proceeds.

That approach fits the deployment's persistent filesystem. Moving to multiple hosts or object storage would require a publication mechanism with appropriate concurrency and consistency guarantees.

There is another boundary to reproducibility: a data snapshot preserves inputs and settings. If a future application changes the evaluation algorithm, that rule would need versioning too.

6. Keep the calculator independent from visitor accounts

The public application reads published snapshots. Editorial work happens through Payload CMS and PostgreSQL, while visitor inventories, favorites, and saved trades use IndexedDB through Dexie.

Saving a trade writes the trade and its snapshot in one browser database transaction. The application can retain the data needed for the original comparison without creating a visitor account.

Shared offers encode their selected fields as UTF-8 JSON, then base64url, in a URL fragment. The fragment is handled by the client and excluded from the HTTP request. It is still readable by anyone holding the link, so encoding provides portability rather than secrecy. Decoding enforces size limits and validates the resulting trade before evaluation.

Browser storage has a practical cost: clearing site data can remove saved records. Backup import and export matter. Cached releases help when an update request fails, but they do not establish full offline support for a first visit.

Test the boundary users cannot see

These assertions exercise the simplified helpers above:

import assert from "node:assert/strict";

assert.equal(
  parseValue("0.1") + parseValue("0.2"),
  parseValue("0.3"),
);

assert.equal(
  classifyCompleteTrade(parseValue("98"), parseValue("100")),
  "fair",
);

assert.equal(
  classifyCompleteTrade(parseValue("97.999999"), parseValue("100")),
  "win",
);

assert.equal(
  classifyCompleteTrade(parseValue("100"), parseValue("97.999999")),
  "lose",
);
Enter fullscreen mode Exit fullscreen mode

The application tests also cover missing quotes, legitimate zero values, mismatched releases, exact variant selection, and historical evaluations after a newer release is published.

Exact arithmetic preserves the recorded quotes. Their quality still depends on source accuracy, review, and freshness. A calculator should expose enough context for someone to understand what its result means.

Which boundary would you test first in a pricing tool: a decimal cutoff, a missing value, or a saved calculation reopened after a data update?

Top comments (0)