Introspection Forms — Schema-Driven UI for Enterprise Applications


Table of Contents

The Core Idea

Your backend schema already describes every form field you have: its name, its type, whether it’s required, and what values it accepts. Introspection Forms reads that description and turns it into a working, validated, reactive form at build time, so we developers never have to restate what the schema already told us. The library is available as an open-source package on npm.


The Problem Space

Enterprise web applications have a way of accumulating forms, and lots of them. A typical enterprise website might have:

  • A multi-step checkout with shipping address, billing address, and payment details
  • User registration with role-dependent fields and conditional sections
  • Insurance or loan applications where visible fields depend on previous answers
  • Internal admin tools with complex filters, bulk-edit dialogs, and settings pages

Every one of those forms follows the same lifecycle. A backend team defines a data model, a frontend team writes a corresponding TypeScript interface, picks input components for each field, wires up labels and error messages, and connects the validation logic. And then both teams get to maintain their parallel definitions forever.

Three categories of bug come out of that arrangement:

  1. Structural drift. The backend adds a field and the frontend never finds out. Or the backend changes a type from String to Int and the frontend carries on sending a string. The API rejects it silently.

  2. Default value errors. A required field gets initialized as undefined instead of an empty string. The form submits, the server returns a 400, and the user sees a generic error message.

  3. Component mismatches. An enum field renders as a text input instead of a dropdown. A date field accepts free text instead of using a date picker. A boolean field shows a dropdown when the design system clearly specifies a checkbox.

That’s the daily reality of maintaining forms across a complex website, and the root cause is always the same one: the form definition exists in two places, and the two copies drift apart.


The Core Concept: Schema as the Source of Truth

Introspection Forms turns the usual workflow on its head. Instead of us describing form fields by hand, a code generator reads the schema, either GraphQL or OpenAPI, and produces everything a form needs in order to render:

Traditional approach — each step is a manual copy:

flowchart LR A["Backend Model<br/>(class Order)"] -->|"copy by hand"| B["Frontend Interface<br/>(interface Order)"] B -->|"copy by hand"| C["Vue Component<br/>(<input v-model=...>)"]

Introspection Forms approach — a single source drives everything:

flowchart LR A1["GraphQL Schema"] --> C["Code Generator"] A2["OpenAPI Spec"] --> C C --> D["Runtime Metadata<br/>(TypeScript const)"] D --> E["Global Plugin Config<br/>(Maps types to your UI components)"] E --> F["useIntrospectionForm()"] F --> G["<IntrospectionForm /><br/>(renders automatically)"]

So you define the data model in the backend. The code generator reads the resulting schema, whether that’s a GraphQL input type or an OpenAPI schema object, and emits a TypeScript constant containing structured metadata for every field. Then you configure the library once to map data types to your design system’s Vue components. From that point on, the form component consumes the metadata and renders the form automatically. No manual interface, no manual component selection per form, and no manual default initialization.


What Gets Generated

For every input type in your GraphQL schema, or every schema object in an OpenAPI spec, the generator produces an IntrospectionType object. That object is pure metadata describing the shape of the data:

// Generated automatically from GraphQL schema — never edited by hand
export const TypeOfContactFormModelInput: IntrospectionType<ContactFormModelInput> = {
  name: 'ContactFormModelInput',

  fields: [
    {
      name: 'emailAddress',
      type: 'string',              // resolved TypeScript type
      originalType: 'String',      // original GraphQL type
      isArray: false,
      isNullable: false,           // String! → required
      isEnum: false,
      enumValues: [],
      defaultValue: '',            // appropriate zero-value
    },
    {
      name: 'contractTransferReason',
      type: 'enum',
      originalType: 'ContractTransferReason',
      isArray: false,
      isNullable: true,
      isEnum: true,
      enumValues: ['Death', 'Divorce', 'Gift'],  // extracted from schema
      defaultValue: null,
    },
    {
      name: 'files',
      type: 'object',
      originalType: 'ContactFormFileInput',
      isArray: true,               // [ContactFormFileInput] → array
      isNullable: true,
      isEnum: false,
      enumValues: [],
      defaultValue: [],
    },
    // ... every field in the input type
  ],

  create(defaultValues?: Partial<ContactFormModelInput>): ContactFormModelInput {
    return {
      emailAddress: '',
      contractTransferReason: null,
      files: [],
      // ... all fields with schema-appropriate defaults
      ...(defaultValues || {}),
    } as ContactFormModelInput
  },
}

In plain terms, that generated object is a machine-readable blueprint for a contact form. It lists every field the form needs, so email address, contract transfer reason and file uploads, along with metadata like whether the field is required, which values are valid, and what its default ought to be. The create() function then produces an empty model object with the correct defaults for every field, ready to bind to your UI. Nobody writes or maintains that blueprint by hand, because it gets regenerated from the backend schema whenever the model changes.

Three things worth noticing:

  1. Every field carries rich metadata. Not just a name and a type, but nullability, enum membership, array status and the original schema type name, whether that came from GraphQL or OpenAPI. That’s enough information to pick a rendering component at run time.

  2. The create() factory produces correctly-typed instances. Required strings default to '', required booleans to false, nullable fields to null, arrays to [], and nested objects to their own create() calls. No more undefined surprises.

  3. Nested types reference each other. If a field’s type is another input object, something like AdresseViewModelInput, then the generated file imports that type’s introspection constant and calls its create() inside the parent’s factory.


How to Get It Running (The 3-Step Setup)

So let’s walk through the three steps you need in order to render a form: generation, global mapping and rendering.

Step 1: Run the Code Generator

First, point the generator at your GraphQL or OpenAPI schema. You’d usually set this up as a build step, something like npm run codegen. It reads the schema and writes out the IntrospectionType metadata files I showed above.

Step 2: Provide and Map Your Components (Once per project)

Here’s the most important thing to understand: Introspection Forms does not provide your UI components. You have to provide your own inputs, checkboxes and date pickers, whether those are built in-house or come from a component library like Vuetify or PrimeVue.

You tell Introspection Forms which of your components to use for which data types by configuring the global Vue plugin, and you only do that once, when you initialize your app:

// main.ts
import { createApp } from 'vue'
import { IntrospectionFormsPlugin } from '@softwareproduction/introspection-forms/plugin'
import IntrospectionForm from '@softwareproduction/introspection-forms/components/IntrospectionForm.vue'

// Import YOUR design system's components
import MyTextInput from './components/MyTextInput.vue'
import MyCheckbox from './components/MyCheckbox.vue'
import MyDatePicker from './components/MyDatePicker.vue'

const app = createApp(App)

// Tell Introspection Forms how to map schema types to your components
app.use(IntrospectionFormsPlugin, {
  // Register the built-in form component globally
  components: { IntrospectionForm },

  defaults: {
    // 1. Map by exact field name matching (regex)
    byFieldName: [
      { regexp: /^email/i, config: { component: MyTextInput, props: { type: 'email' } } },
      { regexp: /password/i, config: { component: MyTextInput, props: { type: 'password' } } }
    ],
    // 2. Map by original backend schema types
    byOriginalType: {
      DateTime: { component: MyDatePicker },
    },
    // 3. Map by standard schema scalar types
    byFieldType: {
      string: { component: MyTextInput },
      boolean: { component: MyCheckbox },
    }
  }
})

Because of that global mapping, a form with 50 fields in it doesn’t mean importing and wiring up 50 input components by hand. The system inspects the generated metadata for each field, consults this global configuration, and picks the correct Vue component itself.

Step 3: Render the Form

Once your components are mapped, creating a form takes almost no boilerplate at all. You import the generated metadata and list the fields you want to show. The useIntrospectionForm composable turns that inert metadata into a reactive runtime configuration.

import { TypeOfContactFormModelInput } from './generated/introspection'

// 1. Create a reactive model with perfect default values
const data = ref(TypeOfContactFormModelInput.create())

// 2. Define which fields to show
const form = useIntrospectionForm(TypeOfContactFormModelInput, {
  firstName: true,  // Renders MyTextInput
  lastName: true,   // Renders MyTextInput
  emailAddress: true, // Renders MyTextInput with type="email" (matched via regex)
  newsletter: true, // Renders MyCheckbox (matched via boolean type)
})
<template>
  <IntrospectionForm :form="form" :model="data" @submit="sendData">
    <button type="submit">Submit</button>
  </IntrospectionForm>
</template>

And that’s a complete, working form. firstName: true tells the system to render that field using the globally registered component for strings, so MyTextInput.

Now, true falls back to the global defaults, but you’re never locked in. You can override the component, customize its props, or make any field aware of the live model by handing it an object instead of a boolean:

const form = useIntrospectionForm(TypeOfContactFormModelInput, {
  firstName: true,
  emailAddress: true,
  newsletter: {
    // Override the default mapped component
    component: MyToggleSwitch,
    // Make properties reactive based on the live model state
    disabled: (model) => !model.emailAddress,
    span: 2, // span across 2 grid columns
  }
})

That way the 80% of standard cases get handled by your global conventions, while the 20% that are complex and highly dynamic stay easy to express.


Runtime Capabilities Unlocked

By generating all the structural boilerplate from the schema, Introspection Forms doesn’t just save you keystrokes. It also unlocks runtime capabilities that are difficult and error-prone to get right in hand-coded forms:

  • Proper type safety. The generated metadata is strongly typed as IntrospectionType<ContactFormModelInput>. The configuration composable, useIntrospectionForm, constrains your field keys to the actual model properties, so a typo in a field name becomes a compile error rather than a runtime bug.
  • Value conversion at the boundary. HTML inputs always produce strings, things like "true" and "42". The framework intercepts those values and coerces them into the types the schema expects, so true and 42, before they reach the model. That gets rid of an entire class of subtle payload bugs.
  • Dynamic, reactive configurations. Real forms change based on what the user does. Every configuration property, so visibility, disabled state, grid span or even specific component props, can be a reactive function that evaluates the live model.
  • Composition that just works. Complex models with nested objects, like a checkout form with separate billing and shipping addresses, are handled by nesting useIntrospectionForm calls. The form structure ends up mirroring the data structure exactly.
  • Cross-stack validation. Introspection Forms integrates natively with the Dryv framework, which lets C# validation rules run identically on the Vue frontend without duplicating any logic.

And best of all, the approach is backend-agnostic. The generators work by reading either GraphQL introspection queries or OpenAPI (Swagger) specifications. As long as your API has a schema, you can derive your forms from it.


Organizational Advantages

Beyond the runtime capabilities, this approach changes how a team maintains forms over the whole lifecycle of an enterprise application:

Single Source of Truth

The backend model is the only place form fields are defined. The GraphQL schema is derived from it, and the introspection metadata is generated from the schema. There’s exactly one definition, and everything else is computed.

Zero Boilerplate for Standard Forms

A simple form needs no component imports of its own, no type definitions, no default value initialization and no label configuration. Because the plugin configuration is the single global source of truth for component mapping, setting firstName: true lets the system work out the correct component entirely from schema types and naming conventions.

Safe Schema Evolution

When the backend adds a field, the next code generation run produces updated metadata. If your form config doesn’t reference the new field, nothing changes. If it does, the field turns up automatically with the right component and defaults. And if the backend removes a field, the TypeScript compiler flags every form still referencing it.

Consistent Component Usage

The global configuration plugin means that every email field across every form renders with the same component, the same input type and the same keyboard hint. Design system consistency gets enforced at the configuration level rather than by developer discipline.

Composable Architecture

Each part of the system is useful on its own:

  • The generated metadata can be used for anything that needs field information, so not just forms but table columns, API documentation or test data factories too.
  • The create() factory can be used anywhere you need a correctly-initialized model: in tests, in stories, in server handlers.
  • Dryv validation works with or without Introspection Forms.
  • The <IntrospectionForm> component works with or without validation.

Developer Experience

Adding a new field to an existing form goes like this:

  1. Add the field to the backend model, whether that surfaces as a GraphQL schema change or an OpenAPI spec update.
  2. Run the code generator, so npm run codegen or whatever yours is called.
  3. Add newField: true to the form config, or leave it out if the form should show all fields anyway.

And that’s the entire workflow. The component, the label, the default value and the type conversion are all handled for you.


When Not to Use It

Introspection Forms is built for forms whose structure closely mirrors a backend data model. It is not the right tool for:

  • Highly custom layouts where the field arrangement doesn’t follow a grid pattern.
  • Forms with no backend model at all, so purely client-side interactions like search filters or preference toggles.
  • Wizard-style UIs where each step’s rendering depends on a completely different component tree, although nested forms do handle moderate complexity fine.

For those cases, plain Vue composition with useDryv() for the validation gives you the flexibility you need.


The Bigger Picture

Introspection Forms is really one instance of a broader architectural principle: derive, don’t duplicate. The same principle applies to GraphQL type generation, to validation rule translation, and to rich text fragment generation in the wider application.

The investment goes into the generators, so a GraphQL codegen plugin that walks the schema AST and an OpenAPI generator that parses spec files, each of them emitting the same TypeScript constants. Once those exist, every new form in the application benefits automatically. The cost of building a form drops from hours to minutes, and the cost of maintaining one drops to somewhere near zero, because the maintenance happens at the schema level and the forms just follow along.

Task Traditional Introspection Forms
First form ~8 hours ~12 hours (build the generator)
Second form ~6 hours ~30 minutes
10th form ~6 hours ~20 minutes
Schema change ~2 hours × N forms Re-run code generator + done
Component redesign ~2 hours × N forms Update global config + done

The break-even point arrives pretty quickly. By the third form the generator has paid for itself, and by the tenth the accumulated savings are measured in weeks.


Resources


Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.

Hero image credit: Photo by Mathias Reding on Pexels