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:
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.
- 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.
Problem
An
invariantviolation is only ever reportable as prose.InvalidEntitycarriesissues: SchemaIssues, and for an invariant the entry has amessageand nopath— 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:
invariant, and lose the code; orTaggedError, and keep the code but give upinvariantsentirely.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,
invariantsholds 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:
messageis 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.Sketch
The smallest thing that would have unblocked me is an optional third argument, surfacing on the issue:
Optional, so nothing existing changes; absent on schema-derived issues, the same way
pathis absent on invariant ones. Ifcodewere generic over a string-literal union,InvalidEntitycould 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:
invariantreturn aTaggedErrorinstead of aboolean/message pair, and haveInvalidEntityaggregate them;rulesoption besideinvariants, typed to produce tagged errors, leavinginvariantsas the prose-only fast path.The tension I can see
Making
InvalidEntitycarry domain codes pushes it towards being a union of domain errors, which is arguably what aTaggedErrorper 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 holdinginvariantswrong before concluding it was a deliberate scope line.Happy to send a PR for whichever shape you prefer.