Errors
Every fallible entry point returns Result<T, InvalidEntity>. Bad input is modelled as a value; a bug in domain code goes down the separate defect channel.
Snippets on this page assume these imports:
tsimport { P } from "unthrown"; import { Entity } from "@btravstack/entity";Domain vocabulary — entities, brands, factories — is whatever your own domain declares.
Entity.InvalidEntity
class InvalidEntity extends TaggedError("InvalidEntity")<{
readonly entity: string;
readonly issues: SchemaIssues; // readonly StandardSchemaV1.Issue[]
}> {
override message: string; // "<entity>: <path>: <msg>; …" — rendered eagerly
}Reachable as both a value and a type — e instanceof Entity.InvalidEntity and const e: Entity.InvalidEntity. The signatures throughout this reference write it unqualified, the way SomeEntity is also a stand-in; Entity.InvalidEntity is how you spell it. Matching by tag needs no import at all: P.tag("InvalidEntity").
Schema failures carry the failing field's path; an invariants violation has none — that absence distinguishes a whole-entity rule from a field complaint.
message
issues stays the structured API; message is only its human spelling — rendered eagerly, so a log line, a thrown getOrThrow() or a failed test assertion names the entity and the failing fields instead of printing a blank Error:
Organization.make({ id: "not-a-uuid", slug: "" }).getOrThrow();
// throws: Organization: id: Invalid UUID; slug: Too small: expected string to have >=1 charactersEach issue renders as path: message, path segments joined with .; an issue with no path — an invariant — renders as its message alone.
Entity.keysOf(issue) / Entity.renderIssue(issue)
The two helpers an adapter needs to turn an InvalidEntity into a response body, working on one element of issues:
Entity.keysOf(issue); // PropertyKey[] — ["customer", "name"]
Entity.renderIssue(issue); // string — "customer.name: Too small: …"keysOf normalises the issue's path to plain keys. Standard Schema permits a path segment to be a bare PropertyKey or a { key } wrapper — zod emits the bare form, but code written against issue.path directly breaks on the wrapped one, which is why the helper exists. renderIssue is the spelling message is built from, so a hand-assembled error list and a logged message never disagree. Expose an HTTP contract uses both.
Entity.codeOf(issue)
The code a failing Entity.invariant declared, or undefined for an issue that came from a field's schema:
e.issues.map(Entity.codeOf); // ["MISSING_FAILURE_REASON", undefined, …]The code rides on the issue as params.code, zod's slot for a custom issue's metadata, and zod carries it through a nested entity, an array or a union with the path prefixed. So an invariant failing two levels down reads as { path: ["holder", "missions", 0], params: { code: "…" } }.
params is not part of the Standard Schema issue type: Standard Schema standardises message and path, nothing else, which is why this is a helper and not a typed field. It checks the shape rather than trusting it, and returns undefined for a schema-validation issue, since a field's validator supplies no domain code. A field schema's own .refine(…, { params: { code } }) is read the same way.
Do not reach for issue.code: on a zod issue that is zod's own kind ("invalid_type", "too_small", "custom"), not a domain code.
Which channel a failure takes
| Failure | Channel |
|---|---|
| a field fails its own schema | InvalidEntity, issue has a path |
a broken invariants rule | InvalidEntity, issue has no path |
a broken invariants rule, read with inspect | no error: the issue is in violations |
a patch key updateInput does not accept | InvalidEntity, one issue per key at [key] |
| a union payload's discriminant matches nobody | InvalidEntity, one issue at [discriminant] |
computed output failing its own schema | defect |
a computed function throwing | defect |
| a generator throwing, or an async one rejecting | defect |
| subclassing an entity | defect |
| two union members claiming one discriminant value | defect, thrown at declaration time |
A rejected patch key reports which of the three kinds it is — Immutable field — cannot be patched, Computed field — cannot be patched, it is re-derived from its sources, or Unknown field for Rental — at the key's own path, so a PATCH adapter maps it to a 422 naming the field. See entity.update(patch) for why this is stricter than make.
The union's "Invalid discriminant" issue lists the values it knows — Invalid discriminant "robot"; expected one of "user", "service_account" — and sits at the discriminant's own path, so it keys a field-level response like any schema failure. The duplicate-value defect is different in kind: it is a bug in the declaration, so Entity.union throws while the declaration is on the stack, naming both members, instead of letting the last one silently win the dispatch table.
The line between the two columns is argued in Errors are values, and defects are separate.
inspect changes only the second row. A field failing its schema, and every defect, take the same channel under inspect as under make.
Handling both at the edge
Organization.make(row).match({
ok: (org) => respond(200, org.toJSON()),
errCases: (m) =>
m.with(P.tag("InvalidEntity"), (e) => respond(422, e.issues)),
defect: (cause) => {
report(cause);
return respond(500);
},
});Issues are carried structured, exactly as the validator produced them, so keying a field-level error response is a path lookup rather than a string parse. Expose an HTTP contract works this through end to end.
