You have opened someone else’s schema. There is a field called linkedBy, it is nullable, and nothing anywhere says what it means or when it is null. You go looking through call sites to work it out.
Pithy has a rule that exists to stop that happening, and it is one sentence long: every object, enum and union carries a Zod .describe(), and so does every field of every object.
It is not a style preference. The schemas are the object model’s documentation — the only description of what a field means that lives close enough to the field to stay true. And the things that ask what a field is for are multiplying. You, reading the source. A contributor deciding whether a value is nullable. An agent handed the schema and asked what your backend looks like.
So the rule is worth enforcing. The interesting part is what happened when we did.
Nineteen packages, nineteen enforcements
Nineteen packages in the kit declare schemas. Each one carried a schema-descriptions.test.ts that walked its own exported schemas and failed on anything undescribed. Nineteen tests, one rule, and — because each was written where it was needed — nineteen private copies of the same walk.
Copies drift. These drifted in a specific and instructive way:
| how many | what that copy did |
|---|---|
| 3 | demanded a description on the field exactly as written |
| 15 | walked the wrapper chain to find one |
| 1 | stepped through a pipe |
| 1 | descended into an array |
Read the last row again. Exactly one of the nineteen looked inside an array.
An array is the shape that hides an undescribed object. Write z.array(z.object({ … })) and eighteen of those nineteen walks would step up to the array, see something that was not an object, and move on satisfied. So a package’s real coverage was decided by which copy it happened to inherit.
The failure that does not look like one
This is the part worth dwelling on, because it generalizes well beyond Zod.
A wrong answer announces itself. You see the failure, read it, disagree, and go and look. A missing answer does not. It arrives as a passing test — which is exactly the signal you built the test to receive.
The eighteen walks that could not see inside an array were not reporting that those schemas were fine. They were reporting that they had not looked.
So when the nineteen were consolidated into one walk, the design followed from that. The walk keeps an explicit set of leaf kinds — string, number, boolean, date and the rest — and the reason is stated in the source:
/**
* Kinds with no schema inside them. **Listed, so that anything not listed is a hole rather than a
* leaf** — that is the whole difference between a walk that can report and one that quietly cannot.
*/Listing the leaves inverts the failure mode. If Zod adds a container tomorrow, or your schema uses one nobody anticipated, the walk does not glide over it and return a clean result. It does not recognize the shape, so it says so.
Its own test file calls it the gate over the nineteen gates, and names the defect it is written to catch: not a false report, but a walk that stops early and reports nothing.
Where it lives, and why that is not the test folder
The obvious home for this is a test helper. It is not one.
It lives in @pithy-sh/core, the package every capability already depends on, because the rule does not only apply to code we wrote. When you declare capability config of your own, the same CLI surfaces read your descriptions to explain your configuration back to you. Holding your schemas to the rule and holding ours to it are the same operation, so the check has to be somewhere you can reach.
There is a related discipline in where the folder sits. src/schema/ reasons about schemas — it is not a drawer to declare them in. Schemas live next to the thing they describe, because a schema filed away from its subject is a second place to keep in sync.
What it buys
The direct payoff is that the descriptions are reliably there. That sounds modest until you consider what depends on it later — self-documenting config, generated API reference, anything that reads a schema and explains it back to a person. None of those can be retrofitted onto a codebase where most fields are silent, and all of them are cheap on one where none are.
The goal behind the rule is stated in our conventions as a test anyone can apply: a non-expert should be able to configure the right backend from the CLI’s questions alone. That only holds if every question can explain itself, which only holds if every schema can. We are not there yet — the rule is the part that has to come first, because it is the part that stops being possible if you wait.
The indirect payoff is the one we did not plan for. Consolidating nineteen copies into one did not just remove duplication. It removed nineteen different answers to “is this schema documented?” — eighteen of them more optimistic than they had any basis to be.