One schema, three consumers
Data collection forms rot when the field rules live in the input, the submit handler and the API at the same time. A single declarative source fixes the drift.
- Published
- Reading time
- 3 min read
- Filed under
- Architecture, Forms
- Author
- Bibaswan Prasai
A data collection form starts with six fields and one rule: the citizen ID is
required. A year later it has ninety fields, conditional sections, cross-field
constraints, and the same rule expressed in four different places — the input’s
required attribute, a check in the submit handler, a server-side guard, and a
database constraint. Three of them are wrong.
The failure is not that the rules are duplicated. It is that they were never written down as data in the first place.
Rules as data
A validation rule is a value, not a branch. Once it is a value it can be serialised, indexed, tested and rendered:
export const profileSchema = {
citizenId: {
label: "Citizenship number",
type: "text",
required: true,
pattern: /^\d{2}-\d{2}-\d{2}-\d{5}$/,
message: "Format: 12-34-56-78901",
},
wardNo: {
label: "Ward",
type: "number",
required: true,
min: 1,
max: 35,
},
householdSize: {
type: "number",
required: (values) => values.ownsProperty === true,
min: 1,
},
} satisfies Record<string, FieldRule>;required accepting a predicate is the small move that removes most
conditional-rendering logic from the component tree. The form no longer asks
“should I show this?” in JSX; it asks the schema.
Three consumers, one source
The same object now drives everything downstream, and none of the consumers knows about the others.
The renderer maps a field descriptor to a control. Given type, label
and required, it can produce the input, the label association, the
aria-describedby wiring for the error, and the correct keyboard type on
mobile — without a bespoke component per field.
The validator walks the same object and returns a keyed error map. It runs identically on the client for immediate feedback and on the server as the authority, because it is a pure function over values.
export function validate(schema: Schema, values: Values) {
const errors: Record<string, string> = {};
for (const [name, rule] of Object.entries(schema)) {
const required =
typeof rule.required === "function"
? rule.required(values)
: rule.required;
const value = values[name];
if (required && isEmpty(value)) {
errors[name] = `${rule.label ?? name} is required`;
continue;
}
if (!isEmpty(value) && rule.pattern && !rule.pattern.test(String(value))) {
errors[name] = rule.message ?? "Invalid format";
}
}
return errors;
}The API contract is generated rather than maintained. The same descriptors produce the request shape the backend expects, so a field added to the schema cannot be silently dropped in transit.
What this buys you in the field
Offline entry becomes tractable. A queued submission carries its values and the schema version it was captured against, so a record collected on Tuesday still validates against Tuesday’s rules when it syncs on Friday — rather than failing against rules that changed in between.
Error reporting improves for free. Because every rule has an identity, a rejected batch can report which rule rejected which record, instead of a generic 422. For someone entering household data on a tablet in a village office, “ward must be between 1 and 35” is actionable and “validation failed” is not.
And the review step stops being special. A read-only rendering of a record is the same walk over the same schema with a different renderer.
Where to stop
Two warnings, both learned the expensive way.
Do not let the schema grow a rule for layout. The moment it carries
colSpan: 6 you have invented a worse templating language, and the next
redesign has to migrate data instead of CSS. Layout belongs to a separate map
keyed by field name.
Do not try to express every constraint declaratively. Cross-record rules — uniqueness, reconciliation against another table — are queries, not field rules, and forcing them into the schema produces a validator that needs a database connection. Keep those on the server as ordinary code and let the schema cover the ninety per cent that is genuinely mechanical.
The test that the boundary is right: adding a field should be a diff in one file, and adding a kind of field should be a diff in the renderer only.