Take-Home · Part 2
Extend the validation library
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/The shape
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.
The four decisions the brief asks for
1 · How a rule refers to another 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.
2 · Which field the error is reported against
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.
3 · What happens when a dependency is missing or invalid
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.
4 · Where I stopped, and why
Two fixed rule kinds, one operator each — deliberately not a general expression language. Out of scope, on purpose:
- Boolean composition (AND/OR/NOT). "Required when urgent and cardiology" should become a third named rule kind with a clear schema — greppable, documentable, safe — not an
exprmini-language inside JSON. - Arithmetic / derived values (
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. - Cross-record / cross-time rules (uniqueness, non-overlap). Those need the database, not a single-record validator.
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.
Malformed rules fail closed
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.
Verification & honest notes
| 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 |
- The canonical date rule was added to Client B's fixture (a fixture, not the library) so it's exercised against a real definition.
- I deliberately did not add "clinical notes required for urgent referrals" to Client C's fixture: the existing suite asserts an urgent referral with no notes is valid, and inventing a business rule to break a passing test I wasn't asked to change is the wrong trade.
required_ifis covered with synthetic definitions instead.