Get in touch
Engineering

Type-safe forms without the tears

One schema, three consumers, zero places where a field name is a string the compiler has never seen.

By Ovadev2 min read

All posts
  • typescript
  • forms
  • validation

Forms rot in a specific way: the input's name attribute, the validation rule, the server's destructuring and the column all say the same word, and nothing in the toolchain knows they are the same word. Rename one and you find the other three in production.

One schema, derived everywhere#

The pattern is not clever. There is exactly one place a field is declared, and everything else derives from it — including the name attributes, which is the step most codebases skip.

ts
export const contactSchema = {
  name:    { label: "Name",    required: true,  max: 80 },
  email:   { label: "Email",   required: true,  email: true },
  message: { label: "Message", required: true,  max: 4000 },
} as const;

export type ContactField = keyof typeof contactSchema;
export type ContactValues = Record<ContactField, string>;
export type ContactErrors = Partial<Record<ContactField, string>>;

as const is what makes this work: the keys become a literal union instead of string, so anything that takes a ContactField refuses a typo at compile time.

tsx
// the input cannot be given a name that is not in the schema
function Field({ name }: { name: ContactField }) {
  const f = contactSchema[name];
  return <input name={name} required={f.required} maxLength={f.max} />;
}

Validate once, in a function both sides import#

Client-side validation is a courtesy; server-side validation is the actual rule. The mistake is writing them twice. One pure function, imported by the form for instant feedback and by the server action for the decision that counts.

ts
export function validate(values: ContactValues): ContactErrors {
  const errors: ContactErrors = {};
  for (const key of Object.keys(contactSchema) as ContactField[]) {
    const rule = contactSchema[key];
    const value = values[key]?.trim() ?? "";
    if (rule.required && !value) errors[key] = `${rule.label} is required`;
    else if ("email" in rule && value && !value.includes("@")) errors[key] = "That doesn't look like an email";
    else if (rule.max && value.length > rule.max) errors[key] = `${rule.label} is too long`;
  }
  return errors;
}

Errors that survive the round trip#

The error shape is keyed by field, so the server's answer maps straight back onto the inputs without a lookup table, and a field that gains a rule cannot forget to display it.

What we deliberately do not do#

  • No form library. Three of these a year does not justify a dependency with its own mental model.
  • No client-only validation. The client gets the same function, but the server decides.
  • No schema in the database layer. The column mapping derives from the same keys; the database does not import form code.
Ovadev

A software company in Baar, Zug. We build our own products, run them afterwards, and write down what that teaches us.

Get in touch
Read next

More from the studio — engineering first, then the rest of the feed.

All posts
© 2026 Ovadev GmbHMade in Switzerland