Skip to content

Let an invariant carry a domain error code, not just a message #70

Description

@btravers

Problem

An invariant violation is only ever reportable as prose. InvalidEntity carries issues: SchemaIssues, and for an invariant the entry has a message and no path — that absence is documented as the thing distinguishing a rule violation from a field complaint. What it does not carry is any stable identifier for which rule failed.

That is fine when the caller is a human reading a message. It does not work when the caller is an HTTP contract.

A service that exposes its business failures as a discriminated error-code union — the shape ts-rest, oneOf-style OpenAPI responses, or a GraphQL error union all push you towards — needs the code to survive from the domain rule to the response body, because a client keys behaviour off it (which field to highlight, which recovery to offer, which copy to render in the user's locale). A message string cannot do that job: it is not stable, it is not enumerable, and matching on it is exactly the fragility errors-as-values is meant to remove.

So today the choice is:

  • express the rule as an invariant, and lose the code; or
  • express the rule as a method returning your own TaggedError, and keep the code but give up invariants entirely.

I hit this adopting the library on a real aggregate with ~12 such codes and took the second option: the entity declares the shape, the flags and the computed fields, invariants holds exactly one rule (the only one with no code of its own), and every business rule stayed a method returning a tagged error. That works, but it means the feature I most wanted to use is the one I could not.

Why this isn't just "put the code in the message"

Two reasons it has to be structured rather than parsed back out:

  1. message is already doing a different job — it is the human-readable text, and it is a function of the data (describe(d)), so it legitimately varies per instance. A code must not.
  2. Every failing rule reports, not just the first. A caller mapping to a response needs to pick among several codes, which means iterating structured entries, not regexing a joined string.

Sketch

The smallest thing that would have unblocked me is an optional third argument, surfacing on the issue:

invariants: [
  invariant(
    (d) => d.step !== "ERROR" || d.failureReason !== undefined,
    "an entity in step ERROR must carry the reason it failed",
    { code: "MISSING_FAILURE_REASON" },
  ),
]
result.match({
  Err: (e) => e.issues.map((i) => i.code), // ["MISSING_FAILURE_REASON"]
})

Optional, so nothing existing changes; absent on schema-derived issues, the same way path is absent on invariant ones. If code were generic over a string-literal union, InvalidEntity could even carry the union and a caller could match exhaustively — though I would happily take the untyped version.

Alternatives that would also solve it, if the above is the wrong shape:

  • let invariant return a TaggedError instead of a boolean/message pair, and have InvalidEntity aggregate them;
  • a separate rules option beside invariants, typed to produce tagged errors, leaving invariants as the prose-only fast path.

The tension I can see

Making InvalidEntity carry domain codes pushes it towards being a union of domain errors, which is arguably what a TaggedError per rule already is, and there is a real argument that this belongs in caller code precisely so the entity stays about structure. If that is the intended boundary, saying so in the docs would still help — I spent a while assuming I was holding invariants wrong before concluding it was a deliberate scope line.

Happy to send a PR for whichever shape you prefer.

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