Take-Home · Part 2
Add cross-field validation to the field-definition format — so a rule like "the project end date must not be before the project start date" can be declared as data, not written as a one-off if. The format design is the point; here's what I chose and why.
lib/Rules live in an optional top-level rules array, a sibling of fields. A definition with no rules key behaves exactly as before, and rule errors use the same { field, message } shape appended to the same list — so validateRecord(definition, record) → { valid, errors } is unchanged.
There are two rule kinds. That's the central design bet: instead of one general expression language stuffed into JSON, the format has a small vocabulary of rule kinds, the same way the library already has a vocabulary of field types. Two kinds cover the two dominant real families — "field A relates to field B" and "field A is required depending on field B" — and each is trivial to read and to write.
compare — one field against another field or a literal{
"rule": "compare",
"field": "project_end_date",
"op": ">=",
"against": { "field": "project_start_date" },
"report_on": "field",
"message": "Project end date must not be before the project start date"
}
Operators: == != > >= < <=. against is either { "field": "…" } or { "value": … }. Comparison is driven by the left field's declared type — numbers numerically; dates as ISO strings (whose lexicographic order is already chronological, so no timezone traps); text / choice / boolean by equality only. An ordering operator on a non-orderable type is a config error, not a silent pass.
required_if — a field becomes required on a condition{
"rule": "required_if",
"field": "clinical_notes",
"when": { "field": "priority_level", "op": "==", "value": "urgent" },
"message": "Clinical notes are required for urgent referrals"
}
The when condition compares another field to a literal, using the same operators. If it holds and the target field is absent, the error reports on the target field.
By name, through an explicit operand object: { "field": "<name>" } for a field reference, { "value": … } for a literal. The two are distinguished structurally (which key is present), so there's never ambiguity between "the field named 5" and "the number 5." The field must exist in the same definition.
Default is the left field — the subject of the sentence the rule expresses ("end date must not be before start date"), and the field a user reads as "the one I got wrong." Overridable with report_on: "against" or "both" (the latter for a symmetric min/max pair where neither field is privileged). required_if always reports on the missing field.
Cross-field rules are a second layer that only runs when the fields it reads are individually sound — present and not already failing their own per-field validation. If a dependency is missing or invalid, the compare rule is skipped: the field's own error (its required or format error) already tells the user what to fix, and "end must be after start" when start is blank or malformed is noise — or literally meaningless, since you'd be comparing against a non-date. This is a deliberate silent-pass at the cross-field layer, not overall — the underlying field error still makes the record invalid. For required_if, if the condition field is missing or invalid the rule doesn't fire: I won't impose a requirement based on an input I couldn't trust.
Two fixed rule kinds, one operator each — deliberately not a general expression language. Out of scope, on purpose:
expr mini-language inside JSON.amount / turnover < 0.5). That's Client B's eligibility scoring — business logic that also needs an external API call. It belongs in service code (see Part 3, Decision 2), not a validation definition.The test is the one the brief sets: everything above can be written by someone who has never opened validate.js. If a needed rule can't be expressed in these two kinds, that's the signal to add a new named kind — with its own documented schema — not to overload the two that exist.
A rule that can't be interpreted becomes a visible error, never a silent pass — matching the library's existing "unknown field type produces an error rather than a crash." That covers an unknown rule kind, a reference to a field that doesn't exist, an unknown operator, and an ordering operator on a non-orderable type. A bad rule surfaces in tests, not in production.
| Check | Result |
|---|---|
| Original suite (16 tests) | unmodified · all pass |
| New cross-field suite (16 tests) | pass — happy path, missing dep, invalid dep, report_on: both, literals, all four required_if branches, every malformed-rule case |
lib/ client-agnostic | clean — no client names or client field names |
required_if is covered with synthetic definitions instead.