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).
-
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).
-
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.
Problem
Struct.mapFields,Tuple.mapElements, andUnion.mapMembersbuild a brand-new schema and by default drop every.check(...)constraint attached to the receiver. Preserving them requires the deliberately scaryunsafePreserveChecks: trueoption — 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, sotschas nothing to say, and the only symptom is that invalid data starts decoding successfully at runtime.Verified against
effect@4.0.0-beta.101with 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 tounsafePreserveChecksand add warning (documents both directions of the footgun)79b0ad4eb— addmapFieldsto the Class interface46f3aa485— addtagDefaultOmitAPI (docs push themapFieldstagging idiom)Reproduction
tsc --strictreports nothing on either variant. At runtime,Taggedaccepts{ min: 10, max: 1 }(themin <= maxfilter is silently discarded), whileOnlyMinrejects the perfectly valid{ min: 5 }because the preserved check dereferences the removedmaxkey.Proposed rule behavior
Report on
mapFields/mapElements/mapMemberscall expressions whose receiver is a SchemaStruct/Tuple/Unionand 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).When the options argument does not set
unsafePreserveChecks: true, report on the call:Code fix suggestion: move the
.check(...)call aftermapFields(safe when the mapping only adds fields, as in the tagging idiom).When
unsafePreserveChecks: trueis 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:Must NOT flag:
.check(...)(plain structs, imported schemas, multi-assignment identifiers);unsafePreserveChecks: truewith an additive-only mapping (e.g.Struct.assign);.check(...)applied to the result of the mapping call.No existing rule overlaps:
schemaStructWithTagandredundantSchemaTagIdentifierdeal 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 theschema*prefix family and covers all three mapping APIs, not justmapFields.mapFieldsDropsChecks— names the most common entry point directly, at the cost of undersellingmapElements/mapMembers.discardedSchemaChecks— result-oriented phrasing, but reads less like the existing verb-anchored names.