A comparison table becomes dangerous when it produces a precise-looking answer for values that were never comparable.
I ran into this while implementing a Gear comparison view. Each item could expose base stats and inherent modifiers. Values could be flat numbers, percentages, or per-second rates. Either side could also omit a field entirely.
The tempting implementation was to join rows by the visible label and subtract right from left. That would have allowed a flat Attack Damage value to collide with an Attack Damage percentage, and it would have encouraged treating a missing value as zero.
The safer implementation turned out to be a small Map keyed by three pieces of semantic identity.
Model the comparison result explicitly
The result row needs to preserve more than two numbers:
type StatSource = "base" | "inherent";
interface ComparisonRow {
key: string;
label: string;
source: StatSource;
unit: "flat" | "percent" | "per-second";
left: number | null;
right: number | null;
delta: number | null;
}
source distinguishes an equipment baseline from an attached modifier. unit prevents visually similar values from being treated as interchangeable. null represents an unavailable published value rather than a measured zero.
Those fields are part of the comparison contract. If they are discarded before the UI layer, no amount of careful table formatting can recover the original meaning.
Use a semantic composite key
The comparison groups rows with a key shaped like this:
const key = `${source}:${stat.key}:${stat.unit}`;
A label alone is insufficient. Consider these records:
base:attack-damage:flat
inherent:attack-damage:flat
inherent:attack-damage:percent
base:attack-speed:per-second
inherent:attack-speed:percent
They may share words in the interface, but they answer different questions. The composite key ensures that only rows with the same stat identity, source, and unit land in the same comparison bucket.
This pattern is useful outside games. Pricing tiers can have monthly and annual amounts. Analytics can have counts and rates. Hardware can have nominal and measured values. A comparison should match on semantics, not whichever label looks closest.
Accumulate each side without erasing null
The implementation walks both projections and fills the same row map:
for (const [side, projection] of [
["left", left],
["right", right]
] as const) {
for (const { source, stat } of entries(projection)) {
const key = `${source}:${stat.key}:${stat.unit}`;
const row = rows.get(key) ?? {
key,
label: stat.label,
source,
unit: stat.unit,
left: null,
right: null,
delta: null
};
row[side] = (row[side] ?? 0) + stat.value;
rows.set(key, row);
}
}
The ?? 0 is used only after a stat entry is known to exist on that side. It supports multiple normalized entries in the same semantic bucket. It does not globally coerce an absent side to zero.
That distinction matters. There is a large difference between “this bucket contains entries totaling zero” and “this release does not provide this bucket.”
Compute a delta only when both sides exist
Delta calculation is intentionally boring:
const compared = [...rows.values()].map((row) => ({
...row,
delta:
row.left === null || row.right === null
? null
: row.right - row.left
}));
If either value is unavailable, the delta is unavailable. The formatter renders null as an em dash.
This avoids a common false conclusion. Suppose the right item publishes an inherent +22.7% modifier and the left item has no matching field. Displaying a +22.7% delta would assert that the left side is known to be zero. Returning null says only what the data supports: there is no compatible pair to subtract.
Formatting must follow the unit
The formatter receives the unit from the row rather than guessing from the label:
function format(value: number | null, unit: Unit) {
if (value === null) return "—";
const sign = value > 0 ? "+" : "";
const suffix =
unit === "percent" ? "%" :
unit === "per-second" ? "/s" : "";
return `${sign}${value}${suffix}`;
}
This keeps +8, +8%, and +8/s visibly distinct. It also lets the comparison logic remain numeric while the presentation layer owns signs, separators, precision, and suffixes.
A valid delta is still not a verdict
Even when the subtraction is mathematically valid, interpretation has a boundary.
B - A = +3 means the right record has three more units in that compatible row. It does not prove higher DPS or a universal Best-in-Slot choice. Those conclusions would require inputs such as hero scaling, skills, enemy defenses, buffs, rotations, and clear time that this comparator does not model.
The UI states that boundary below the table. The result is arithmetic over published catalog fields, not a recommendation engine.
That product decision also keeps adjacent questions separate. Acquisition routes, drop probabilities, and duplicate-item value can share item records without being folded into the same score.
Practical checks for comparison code
Before shipping a comparator, I now ask:
- Can two records share a display name while representing different entities?
- Does every numeric value retain its unit and provenance?
- Are absent values distinct from measured zero?
- Can incompatible values accidentally share a grouping key?
- Does the UI explain what a positive delta does and does not mean?
- Are broader recommendations being inferred from inputs the system never received?
If any answer is unclear, the table may be more confident than the data.
The implementation described here powers the Gear Calculator in Task Bar Hero Wiki, an independent community reference. The catalog is release-scoped and may be incomplete, delayed, or incorrect; the tool compares the fields currently published rather than promising coverage for every version or platform.
For implementation context, the live comparison surface is available at:
https://taskbarherowiki.app/tools/gear-calculator
Disclosure: I used AI assistance to help structure and edit this article. I reviewed the implementation details, code examples, and product claims against the source implementation and the live comparison output, and I am responsible for the final text.
Top comments (0)