Skip to content

Rule proposal: Struct.evolve silently ignores transform keys absent from the target struct #488

Description

@mattiamanzati

Problem

Detects properties in the transformer object passed to Struct.evolve (data-first or pipe form) whose names are not keys of the struct being evolved. Because Evolver is an all-optional mapped type behind a generic E extends Evolver, extra keys pass type-checking, yet the implementation iterates only the target's own keys, so the stray transform never runs. Re-verified against effect 4.0.0-beta.104: both forms compile cleanly with a bogus key. This bites hardest after a field rename, when old evolve call sites keep the stale key and become dead no-ops.

Why the compiler is silent / what breaks at runtime: The evolve call compiles but returns the struct with the intended field untouched — the update quietly stops happening after a rename or an AI-driven refactor leaves garbage keys behind, with no compiler or runtime signal.

Both examples below type-check with zero errors against effect@4.0.0-beta.104 (re-verified) 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: struct-evolve-extra-keys-silently-ignored
// BAD: the transformer passed to Struct.evolve contains a key ("zipCode")
// that is not a key of the struct being evolved. Evolver<S> is an
// all-optional mapped type behind a generic `E extends Evolver<S>`, so once
// the transformer is not a fresh inline literal (extracted const, reusable
// updater) and shares at least one real key, neither excess-property nor
// weak-type checking fires — yet the implementation iterates only the
// target's own keys, so the stray transform never runs. Classic aftermath
// of a field rename (zipCode -> zip): the extracted updater keeps compiling
// while half of it became a silent no-op.
import { pipe, Struct } from "effect"

const model = {
  name: "alice",
  zip: "00000" // field was renamed from `zipCode` to `zip`
}

// updater extracted before the rename; the stale `zipCode` key remains
const normalize = {
  name: (s: string) => s.toUpperCase(),
  zipCode: (s: string) => s.padStart(5, "0")
}

// pipe form: compiles, `name` runs, `zipCode` is silently dropped
const updatedPipe = pipe(model, Struct.evolve(normalize))
void updatedPipe // { name: "ALICE", zip: "00000" } — zip untouched

// data-first form: same silent partial no-op
const updatedDataFirst = Struct.evolve(model, normalize)
void updatedDataFirst

Good

// RULE: struct-evolve-extra-keys-silently-ignored
// GOOD: every key of the transformer object is an actual key of the struct
// being evolved, so each transform really runs. After the zipCode -> zip
// rename, the call site was updated to use the new key name.
import { pipe, Struct } from "effect"

const model = {
  name: "alice",
  zip: "00000"
}

// pipe form: `zip` is a key of typeof model, transform is applied
const updatedPipe = pipe(
  model,
  Struct.evolve({ zip: () => "12345" })
)
void updatedPipe // { name: "alice", zip: "12345" }

// data-first form
const updatedDataFirst = Struct.evolve(model, { zip: (z) => z.padStart(5, "0") })
void updatedDataFirst

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

structEvolveExtraKeysSilentlyIgnored

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