Skip to content

Rule proposal: Hash.hash output used as a unique identity key silently collides #482

Description

@mattiamanzati

Problem

Detects Effect Hash values being used as if they were unique identifiers: a Hash.hash(...) result stored as a Map/Record/cache key, or two Hash.hash(...) results compared with ===/!== as a substitute for equality. In Effect, a hash is only a bucketing hint that accelerates Equal.equals; it is deterministic but explicitly not guaranteed unique, so distinct payloads can share a hash.

Why the compiler is silent / what breaks at runtime: Two different payloads can produce the same hash, so the cache hands back data belonging to a different query — exactly the 'wrong data back' bug reported live on stream; it type-checks fine because the hash is just a number.

Both examples below type-check with zero errors against effect@4.0.0-beta.104 (re-verified against that version) under the monorepo's strict tsconfig, verified with an isolated per-proposal tsconfig — so the compiler offers no protection here and a diagnostic is the only static safety net.

Bad — compiles cleanly, the rule should flag this

// RULE: hash-as-unique-key
// BAD: Hash.hash(...) is a bucketing hint, not a unique identity. It is
// deterministic but explicitly NOT collision-free: two different payloads can
// hash to the same number. Using the hash as a Map/Record/cache key, or
// comparing two hashes with ===/!== as a stand-in for equality, silently
// conflates distinct values — the cache hands back another query's result.
// It type-checks fine because a hash is just a number.
import { Hash } from "effect"

interface QueryPayload {
  readonly table: string
  readonly filter: string
  readonly page: number
}

interface Result {
  readonly rows: ReadonlyArray<string>
}

// The hash number is used as the cache identity: any other payload that
// happens to collide will read/overwrite this entry.
const cache = new Map<number, Result>()

export const remember = (payload: QueryPayload, result: Result): void => {
  const key = Hash.hash(payload) // flagged: hash stored as unique key
  cache.set(key, result)
}

export const lookup = (payload: QueryPayload): Result | undefined =>
  cache.get(Hash.hash(payload)) // flagged: hash used for lookup identity

// Record keyed by hash — same problem, different container.
const byHash: Record<number, Result> = {}
export const rememberRecord = (payload: QueryPayload, result: Result): void => {
  byHash[Hash.hash(payload)] = result // flagged
}

// Comparing raw hashes as a substitute for equality: equal hashes do NOT
// imply equal payloads, so this "same query" check is wrong on collisions.
export const isSameQuery = (a: QueryPayload, b: QueryPayload): boolean =>
  Hash.hash(a) === Hash.hash(b) // flagged: hash equality treated as identity

export const isDifferentQuery = (a: QueryPayload, b: QueryPayload): boolean =>
  Hash.hash(a) !== Hash.hash(b) // flagged (this direction is actually sound
// only for inequality, but the rule flags the identity-comparison pattern)

Good

// RULE: hash-as-unique-key
// GOOD: key caches by the value itself in an Equal-aware structure (HashMap
// uses Hash.hash internally only as a bucket hint and falls back to
// Equal.equals on collision), and compare values with Equal.equals — never by
// comparing raw Hash.hash numbers.
import { Equal, HashMap, Option } from "effect"

interface QueryPayload {
  readonly table: string
  readonly filter: string
  readonly page: number
}

interface Result {
  readonly rows: ReadonlyArray<string>
}

// HashMap keys are compared structurally with Equal.equals, so a hash
// collision between two distinct payloads can never mix up entries.
let cache = HashMap.empty<QueryPayload, Result>()

export const remember = (payload: QueryPayload, result: Result): void => {
  cache = HashMap.set(cache, payload, result)
}

export const lookup = (payload: QueryPayload): Result | undefined =>
  Option.getOrUndefined(HashMap.get(cache, payload))

// Equality between payloads goes through Equal.equals (deep structural
// equality in Effect), which uses the hash only as a fast-path rejection.
export const isSameQuery = (a: QueryPayload, b: QueryPayload): boolean =>
  Equal.equals(a, b)

export const isDifferentQuery = (a: QueryPayload, b: QueryPayload): boolean =>
  !Equal.equals(a, b)

Where this came up

Mined from the Effect Office Hours playlist; deduplicated against all implemented tsgo diagnostics and prior rule-proposal issues.

Proposed rule name

hashAsUniqueKey

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions