Skip to content

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:

lib/schema.ts
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.