Skip to content

Rule proposal: mapFields on a schema with .check(...) silently discards the checks #435

Description

@mattiamanzati

Problem

Struct.mapFields, Tuple.mapElements, and Union.mapMembers build a brand-new schema and by default drop every .check(...) constraint attached to the receiver. Preserving them requires the deliberately scary unsafePreserveChecks: true option — and that option is itself a footgun when the mapping removes or renames keys the check reads.

The docs' own recommended tagging idiom (schema.mapFields(Struct.assign({ kind: Schema.tagDefaultOmit(...) }))) routes users straight into silently losing cross-field filters: the result type is what the user expects, so tsc has nothing to say, and the only symptom is that invalid data starts decoding successfully at runtime.

Verified against effect@4.0.0-beta.101 with TypeScript 7.0.2 --strict: both directions of the footgun compile with zero errors and misbehave at runtime.

The Effect team has wrestled with exactly this:

  • c894b568a — rename to unsafePreserveChecks and add warning (documents both directions of the footgun)
  • 79b0ad4eb — add mapFields to the Class interface
  • 46f3aa485 — add tagDefaultOmit API (docs push the mapFields tagging idiom)

Reproduction

import { Effect, Schema, Struct } from "effect"

const Range = Schema.Struct({ min: Schema.Number, max: Schema.Number })
  .check(Schema.makeFilter(({ min, max }: { min: number; max: number }) => min <= max))

// BUG: the min <= max check is silently gone
const Tagged = Range.mapFields(Struct.assign({ kind: Schema.tagDefaultOmit("range") }))

const invalid = { min: 10, max: 1 }
Effect.runPromise(Effect.result(Schema.decodeUnknownEffect(Range)(invalid)))
  .then((r) => console.log("Range:", r._tag))   // Failure
Effect.runPromise(Effect.result(Schema.decodeUnknownEffect(Tagged)(invalid)))
  .then((r) => console.log("Tagged:", r._tag))  // Success — check dropped

// Second direction: preserving checks across a key-removing mapping
const OnlyMin = Range.mapFields(Struct.pick(["min"]), { unsafePreserveChecks: true })
// decoding the VALID input { min: 5 } now fails: the preserved check reads the removed `max`

tsc --strict reports nothing on either variant. At runtime, Tagged accepts { min: 10, max: 1 } (the min <= max filter is silently discarded), while OnlyMin rejects the perfectly valid { min: 5 } because the preserved check dereferences the removed max key.

Proposed rule behavior

Report on mapFields / mapElements / mapMembers call expressions whose receiver is a Schema Struct / Tuple / Union and can be traced to a .check(...) call — either syntactically (X.check(...).mapFields(...)) or through an identifier whose single-assignment initializer ends in .check(...). Restricting to these two shapes limits recall but keeps the rule precise (no cross-module or flow-sensitive tracing).

  1. When the options argument does not set unsafePreserveChecks: true, report on the call:

    mapFields builds a new schema and discards the .check(...) constraints attached to the receiver. Re-attach the checks on the result, or pass unsafePreserveChecks: true if the mapping provably keeps every key the checks read.

    Code fix suggestion: move the .check(...) call after mapFields (safe when the mapping only adds fields, as in the tagging idiom).

  2. When unsafePreserveChecks: true is passed and the mapping callback removes or renames keys — recognizable simple shapes only (Struct.pick, Struct.omit, Struct.evolveKeys, Struct.renameKeys) — report on the option:

    The preserved checks were written against the original fields; this mapping removes/renames keys, so the checks may read missing properties.

Must NOT flag:

  • receivers with no traceable .check(...) (plain structs, imported schemas, multi-assignment identifiers);
  • calls already passing unsafePreserveChecks: true with an additive-only mapping (e.g. Struct.assign);
  • .check(...) applied to the result of the mapping call.

No existing rule overlaps: schemaStructWithTag and redundantSchemaTagIdentifier deal with tag declaration style, not with constraint loss through mapping; this rule complements them by covering the tagging idiom's runtime hazard.

Proposed rule name

  • schemaMapDropsChecks — matches the schema* prefix family and covers all three mapping APIs, not just mapFields.
  • mapFieldsDropsChecks — names the most common entry point directly, at the cost of underselling mapElements/mapMembers.
  • discardedSchemaChecks — result-oriented phrasing, but reads less like the existing verb-anchored names.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions