# Build an evidence-backed comparison site

> Turn comparison research into typed data with source IDs, review dates, uncertainty states, and semantic invariants that fail the build.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-09 | Updated: 2026-08-03 | Topics: [TypeScript](https://flaviocopes.com/tags/typescript/) | Canonical: https://flaviocopes.com/evidence-backed-comparison-site/

A comparison site should treat claims like application data.

Do not bury prices, limitations, and sources inside long pages of prose.

Model them as fields. Then reject records that contradict themselves.

## Start with the facts you need

A payment-provider profile might contain:

~~~ts
type Provider = {
  slug: string
  name: string
  model: 'merchant-of-record' | 'direct-payments'
  legalSeller: 'provider' | 'merchant'
  pricing: Pricing
  responsibilities: Responsibility[]
  sources: Source[]
  lastReviewed: string
  nextReview: string
}
~~~

The important fields are not only the visible ones.

`lastReviewed` and `nextReview` turn freshness into data. `legalSeller` makes a major responsibility explicit. `sources` makes evidence addressable.

## Model uncertainty

Do not force every price into the same numeric shape:

~~~ts
type Pricing =
  | {
      kind: 'public'
      percentage: number
      fixed: number
      sourceIds: string[]
    }
  | {
      kind: 'negotiated' | 'contradictory'
      percentage: null
      fixed: null
      sourceIds: string[]
    }
~~~

`null` means the exact value is not available, not free. The calculator article can own the arithmetic details; the schema only needs to preserve this distinction.

## Give sources stable local IDs

Store sources with the record:

~~~json
{
  "id": "pricing",
  "url": "https://provider.com/pricing",
  "reviewedOn": "2026-07-29"
}
~~~

Claims then reference `pricing`, not a loose footnote number:

~~~json
{
  "kind": "public",
  "percentage": 5,
  "fixed": 0.5,
  "sourceIds": ["pricing"]
}
~~~

This lets a validation script detect missing or renamed evidence.

## Validate relationships

Schema validation checks shapes. Comparison data also needs semantic checks.

For example:

~~~js
for (const provider of providers) {
  const expectedSeller = provider.model === 'merchant-of-record'
    ? 'provider'
    : 'merchant'

  assert(
    provider.legalSeller === expectedSeller,
    `${provider.slug}: inconsistent legal seller`
  )
}
~~~

An uncertain price must not expose an exact formula:

~~~js
if (
  provider.pricing.kind === 'negotiated' ||
  provider.pricing.kind === 'contradictory'
) {
  assert(provider.pricing.percentage === null)
  assert(provider.pricing.fixed === null)
}
~~~

Each pricing source must exist:

~~~js
const sourceIds = new Set(
  provider.sources.map(source => source.id)
)

for (const sourceId of provider.pricing.sourceIds) {
  assert(sourceIds.has(sourceId))
}
~~~

These checks protect meaning, not only syntax.

## Reject stale data

Inject the review date into the validation script:

~~~js
const reviewDate = process.env.EDITORIAL_REVIEW_DATE ??
  new Date().toISOString().slice(0, 10)
~~~

Then enforce:

~~~js
function isIsoDate(value) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false

  const [year, month, day] = value.split('-').map(Number)
  const parsed = new Date(Date.UTC(year, month - 1, day))

  return parsed.getUTCFullYear() === year &&
    parsed.getUTCMonth() === month - 1 &&
    parsed.getUTCDate() === day
}

assert(isIsoDate(reviewDate))
assert(isIsoDate(provider.lastReviewed))
assert(isIsoDate(provider.nextReview))

assert(provider.lastReviewed <= reviewDate)
assert(provider.nextReview >= reviewDate)
assert(provider.nextReview > provider.lastReviewed)
~~~

The regular expression enforces the serialized shape. Parsing rejects impossible calendar dates before the ISO strings are compared.

The environment variable makes historical checks and tests deterministic.

Without it, a build from last month can fail differently today.

## Generate pages from the records

Once the data passes validation, use it everywhere:

- provider profiles
- pair comparisons
- cost calculators
- country checkers
- recommendation tools
- sitemap entries

Derive values from the reviewed record when possible.

Sometimes an execution-oriented dataset is useful. A calculator may keep compact formula records separate from the richer provider profiles.

When you duplicate a fact this way, fail the build if it diverges:

~~~js
assert(
  calculatorRule.percentage ===
    provider.pricing.percentage,
  `${provider.slug}: calculator price differs from evidence record`
)
~~~

The important part is not pretending duplication never happens. It is making drift impossible to ignore.

## Keep research candidates separate

Sometimes a provider looks promising but has not passed review.

Model that state too:

~~~json
{
  "slug": "new-provider",
  "status": "research-candidate",
  "sources": [
    {
      "id": "pricing",
      "url": "https://new-provider.com/pricing",
      "reviewedOn": null
    },
    {
      "id": "legal",
      "url": "https://new-provider.com/legal",
      "reviewedOn": null
    }
  ]
}
~~~

The source shape stays consistent, but `null` records that the page has not been reviewed yet. Published providers must have a real review date.

Do not let candidates quietly appear in published comparisons.

The absence of a record can mean many things. An explicit candidate status tells future you why it is missing.

The core idea is simple: **editorial facts deserve schemas, tests, and failure states too**.
