Skip to content

update() silently strips unknown patch keys and returns Ok #42

Description

@btravers

Summary

entity.update(patch) returns Ok when the patch carries a key the entity does not declare, and silently discards that key. No Result error, no defect, no signal of any kind.

For a library whose premise is sealed construction, invariants and errors-as-values, silently succeeding while dropping caller data is the one outcome that is neither Ok-with-the-requested-change nor an error.

Reproduction

@btravstack/entity@0.2.0, zod@4.3.6, typescript@5.9.3.

import { Entity } from "@btravstack/entity";
import { z } from "zod";

const Id = z.string().brand("Id");
const Amount = z.object({ value: z.number() }).brand("Amount");

class Basic extends Entity("Basic")({
  id: Id,
  plan: z.literal("BASIC"),
}) {}

class Premium extends Entity("Premium")({
  id: Id,
  plan: z.literal("PREMIUM"),
  discount: z.optional(Amount),
}) {}

const basic = Basic.make({ id: "a", plan: "BASIC" }).getOrThrow();

const patched = (basic as unknown as Premium).update({
  discount: { value: 500 } as z.infer<typeof Amount>,
});

console.log("update() result is Ok:", patched.isOk());
console.log("keys after update:", Object.keys(patched.getOrThrow().toJSON()).join(", "));

Output:

update() result is Ok: true
keys after update: id, plan

The caller asked to set discount. It got back a successful Result and an entity without it.

Why the type guard is not sufficient

PatchOf is Partial<Omit<OutputOf<…>, immutable | computed>>, and TypeScript's excess-property check does reject an object literal with an unknown key — I verified that separately, and it correctly rejects both immutable and computed keys.

But the check does not survive the shape real adapters have. Mapping an HTTP request body to a patch produces a Record<string, unknown> built conditionally:

const patch: Record<string, unknown> = {};
if (body.status !== undefined) patch["status"] = body.status;
if (body.discount !== undefined) patch["discount"] = body.discount;
return entity.update(patch);

No literal, no excess-property check, no compile error — and at runtime the key vanishes.

How this surfaces in practice

With a discriminated union built by Entity.union(...), a field can exist on one variant and not another. The natural design is: patching a variant that lacks the field fails, and the caller maps that failure to a 4xx. That design is not expressible — the patch silently no-ops and the caller gets a success for a change that never happened.

The workaround is to build the patch per variant so the branch cannot receive the key, and raise the error explicitly. That works, but it is a workaround for a silent failure, and it only exists if someone happens to test the negative path.

Suggestion

Reject unknown keys in update() — an InvalidEntity with the offending key in path would fit the existing error shape exactly, and would make the failure land at the call site instead of surfacing later as missing data.

If silent stripping is a deliberate choice (mirroring zod's default object behaviour), it is worth stating prominently in the docs, because the type-level guard makes it look impossible and the runtime behaviour is invisible until someone diffs persisted state.

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