research/one-config-for-client-and-server-intake-fields

One Config for Client and Server: A Single Source of Truth for Intake Forms

Engineering Note · October 27, 2026 · 7 minute read · TypeScript · Forms · Architecture

A single source of truth for an intake form means the list of fields exists once, as typed data, and every consumer derives its own view from it: the page that renders inputs, the server that validates, and any interview that fills them. Each extra copy of the field list is a chance for the copies to disagree. We defined our intake once, in one TypeScript config, and derived everything else from it. This note shows the shape, then a runnable reconstruction with a test that proves the sides cannot drift.

The code below is a reconstruction written for this note, not our production code. We ran it with Node 22.22.2, TypeScript 7.0.2 (tsc strict, no errors) and tsx 4.23.15 using node:test. Field names and limits other than the 3000-character “anything else” field are made up for the demo.

What does the config hold?

The intake has two layers. The core fields are the ones every inquiry needs: name, email, company, phone, current website, preferred contact method, and an “anything else” field that accepts up to 3000 characters.

Then there are categories, one for each kind of project we take on. Each category has its own questions, because the useful questions for one kind of project aren’t the useful questions for another. Every question carries a name, a label, a length limit, and optionally a role:

export type Role = "business" | "goal" | "timeline" | "budget";
export type Question = { name: string; label: string; maxLength: number; role?: Role };
export type Category = { id: string; label: string; questions: Question[] };
export type IntakeConfig = { core: Question[]; categories: Category[] };

export const config: IntakeConfig = {
  core: [
    { name: "name", label: "Name", maxLength: 120 },
    { name: "email", label: "Email", maxLength: 254 },
    { name: "message", label: "Anything else", maxLength: 3000 },
    // company, phone, website, contact_method omitted here for space
  ],
  categories: [
    { id: "new-site", label: "New website", questions: [
      { name: "site_business", label: "What does the business do?", maxLength: 500, role: "business" },
      { name: "site_goal", label: "Main goal of the site", maxLength: 500, role: "goal" },
      { name: "site_launch", label: "When do you want to launch?", maxLength: 100, role: "timeline" },
      { name: "site_budget", label: "Budget range", maxLength: 100, role: "budget" },
    ]},
    { id: "automation", label: "AI automation", questions: [
      { name: "auto_process", label: "Which process is slow today?", maxLength: 500, role: "business" },
      { name: "auto_outcome", label: "What outcome do you want?", maxLength: 500, role: "goal" },
      { name: "auto_volume", label: "How often does it happen?", maxLength: 100 },
      { name: "auto_when", label: "Start date", maxLength: 100, role: "timeline" },
    ]},
  ],
};

How do roles connect a generic interview to specific fields?

Our intake can run as a conversational interview. The interview asks generic questions: what the business does, what the main goal is, when they want to start, what budget they have in mind. It doesn’t know which category the visitor chose, and it shouldn’t have to.

Meanwhile, each category has its own field names for those same ideas, so any question can carry a role. We build a role map from the config for the selected category, and when the interview finishes, the generic answers land on whichever fields carry the matching role. Running it against both categories:

new-site  roleMap: {
  business: 'site_business',
  goal: 'site_goal',
  timeline: 'site_launch',
  budget: 'site_budget'
}
automation roleMap: {
  business: 'auto_process',
  goal: 'auto_outcome',
  timeline: 'auto_when'
}

The same four interview answers (business, goal, timeline, budget) produced site_business, site_goal, site_launch and site_budget for the new-site category, and only auto_process, auto_outcome and auto_when for automation. The interview does not contain a table of field names, and the form does not contain a table of interview topics.

How do the page and the server read the same file?

The page generates its input names from the config. The server derives its accepted fields, with a maximum length for each, from the same config:

export function inputNames(cfg: IntakeConfig, categoryId: string): string[] {
  return [...cfg.core, ...find(cfg, categoryId).questions].map((q) => q.name);
}

export function allowlist(cfg: IntakeConfig, categoryId: string): Record<string, number> {
  return Object.fromEntries(
    [...cfg.core, ...find(cfg, categoryId).questions].map((q) => [q.name, q.maxLength]),
  );
}

find looks up the category by id and throws on an unknown one. validate loops over the allowlist: it skips absent fields, rejects non-strings and over-long values, and trims what it keeps. Anything not in the allowlist is never read.

Can client and server drift? A test that says no

The drift test clones the config, adds one question to the new-site category, and compares both outputs before and after:

test("adding one question changes BOTH outputs, in one edit", () => {
  const next = withExtraQuestion(config); // adds { name: "site_pages", maxLength: 50 }
  console.log("page   added:", inputNames(next, "new-site").filter((n) => !inputNames(config, "new-site").includes(n)));
  console.log("server added:", Object.keys(allowlist(next, "new-site")).filter((n) => !(n in allowlist(config, "new-site"))));
  const old = validate(config, "new-site", { site_pages: "12" });
  assert.deepEqual(old.ok && old.clean, {});            // old config: dropped
  const r = validate(next, "new-site", { site_pages: "12" });
  assert.deepEqual(r.ok && r.clean, { site_pages: "12" }); // new config: accepted
});

Output, quoted from the run:

page   added: [ 'site_pages' ]
server added: [ 'site_pages' ]
✔ page inputs and server allowlist always name the same fields
✔ adding one question changes BOTH outputs, in one edit
✔ a field the config does not know is dropped, not stored
✔ the server enforces its own length limit
✔ fields from another category are rejected silently (dropped)
✔ interview answers land on category-specific fields via role
✔ a role with no matching field in a category is dropped silently
✔ lint catches a duplicate name and a doubled role
ℹ tests 8
ℹ pass 8
ℹ fail 0

Because there is no second list, there is no deploy order to think about and no field that the page sends and the server silently drops. The first test in that run checks the same property for every category in the config.

Failure modes we reproduced

The server must not trust the page. The config makes the two sides agree; it does not replace server-side validation. We sent { name: "Ada", is_admin: "true", auto_volume: "daily" } for the automation category and the clean output was { name: 'Ada', auto_volume: 'daily' }. A 3001-character message came back as [ 'message: 3001 > 3000' ].

A role with no field is dropped without a sound. The automation category has no budget question, so an interview that supplied only a budget answer mapped to {}. If that is not what you want, the fix belongs in the config, not in the mapper.

Config mistakes need a lint. Two edits break the single-source idea from inside: a duplicate field name and the same role used twice in one category. A short lint(config) caught both in our run, on a deliberately broken example config:

[
  'new-site: duplicate field name "email"',
  'new-site: role "goal" used twice'
]

Run it in the same test file so a bad edit fails the build before it fails a visitor.

How is the interview brief carried?

When the interview runs, the full brief travels in the “anything else” field. That field already exists and already has a 3000-character limit, so the interview needs no second kind of submission. It is the same trade we describe in the case for boring web architecture: extend what exists before adding something new.

When doesn’t this apply?

This pattern pays off when the same field list is read by more than one consumer. If your form has five fields, one page, and one handler, a config file is more indirection than it is worth. It also helps to keep the config plain data, with no logic in it. The moment the config starts containing behavior, it stops being a source of truth and becomes another place for the code to hide.

Checklist

  • Define core fields and categories in one typed config.
  • Give questions an optional role where a generic flow needs to map onto them.
  • Generate form inputs from the config, and derive accepted fields and length limits on the server from the same file.
  • Let the server validate for itself.
  • Lint the config for duplicate names and doubled roles.
  • Keep the config as data.

Frequently asked questions

How do I keep client and server form validation from drifting apart?

Define the fields once as typed data, then derive both sides from it: the page renders its inputs from the field names, and the server builds its allowlist and length limits from the same list. In our test, adding one question changed both outputs in a single edit, so neither side could be updated alone.

Is client-side validation enough if both sides share one config?

No. A shared config makes the two sides agree about which fields exist; it does not make the client trustworthy. The server must validate for itself. In our run it dropped fields the config did not list and rejected a 3001-character message against a 3000-character limit, regardless of what the page sent.

How can a generic interview map answers onto category-specific form fields?

Give each question an optional role such as business, goal, timeline or budget. For the chosen category, build a map from role to field name, then place each generic answer into the field carrying that role. The interview holds no field names and the form holds no interview topics; only the config knows how they relate.

What happens to a role that a category has no field for?

It is dropped silently. In our reconstruction, the automation category has no budget question, so an interview that supplied only a budget answer mapped to nothing and the output was an empty object. If dropping that answer is not acceptable, add a question with that role or have the config lint fail.

More from Research

← All research