Table of Contents
- The Core Idea
- The Problem Space
- The Core Concept: Schema as the Source of Truth
- What Gets Generated
- How to Get It Running (The 3-Step Setup)
- Runtime Capabilities Unlocked
- Organizational Advantages
- When Not to Use It
- The Bigger Picture
- Resources
The Core Idea
Your backend schema already describes every form field — its name, its type, whether it’s required, and what values it accepts. Introspection Forms is a system that reads that description and turns it into a working, validated, reactive form at build time, so developers never have to restate what the schema already says. The library is available as an open-source package on npm.
The Problem Space
Enterprise web applications tend to accumulate forms — 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
Each of these 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 validation logic. Then both teams maintain their parallel definitions in perpetuity.
Three categories of bugs emerge from this arrangement:
- Structural drift — The backend adds a field, but the frontend doesn’t know. The backend changes a type from
StringtoInt, but the frontend still sends a string. The API rejects it silently.
- Default value errors — A required field is initialized as
undefinedinstead of an empty string. The form submits, the server returns a 400, and the user sees a generic error.
- 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 specifies a checkbox.
These are the daily reality of maintaining forms across a complex website. The root cause is always the same: the form definition exists in two places, and the two copies diverge.
The Core Concept: Schema as the Source of Truth
Introspection Forms inverts the usual workflow. Instead of developers describing form fields manually, a code generator reads the schema — either GraphQL or OpenAPI — and produces everything a form needs to render:
Traditional approach — each step is a manual copy:
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'primaryBorderColor': '#e07020', 'lineColor': '#e07020', 'secondaryColor': '#16213e', 'tertiaryColor': '#0f3460', 'edgeLabelBackground': '#1a1a2e', 'clusterBkg': '#16213e', 'clusterBorder': '#e07020' }}}%%
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:
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'primaryBorderColor': '#e07020', 'lineColor': '#e07020', 'secondaryColor': '#16213e', 'tertiaryColor': '#0f3460', 'edgeLabelBackground': '#1a1a2e', 'clusterBkg': '#16213e', 'clusterBorder': '#e07020' }}}%%
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)"]
The developer defines the data model in the backend. The code generator reads the resulting schema — a GraphQL input type or an OpenAPI schema object — and emits a TypeScript constant containing structured metadata for every field. You then configure the library once to map data types to your design system’s Vue components. From then on, the form component consumes the metadata and renders the form automatically. There is no manual interface, no manual component selection per form, and no manual default initialization.
What Gets Generated
For every input type in the GraphQL schema (or every schema object in an OpenAPI spec), the generator produces an IntrospectionType object. This object is pure metadata that describes 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: this generated object acts as a machine-readable blueprint for a contact form. It lists every field the form needs — email address, contract transfer reason, file uploads — along with metadata such as whether the field is required, what values are valid, and what its default should be. The create() function produces an empty model object with correct defaults for every field, ready to bind to the UI. Developers never write or maintain this blueprint; it is regenerated from the backend schema whenever the model changes.
Three things to notice:
- Every field carries rich metadata — not just a name and type, but nullability, enum membership, array status, and the original schema type name (GraphQL or OpenAPI). This is enough information to choose a rendering component at runtime.
- The
create()factory produces correctly-typed instances — required strings default to'', required booleans tofalse, nullable fields tonull, arrays to[], and nested objects to their owncreate()calls. No moreundefinedsurprises.
- Nested types reference each other — if a field’s type is another input object (like
AdresseViewModelInput), the generated file imports that type’s introspection constant and calls itscreate()inside the parent’s factory.
How to Get It Running (The 3-Step Setup)
To understand how Introspection Forms works in practice, let’s walk through the three steps required 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. This is usually set up as a build step (npm run codegen). It reads the schema and outputs the IntrospectionType metadata files shown above.
Step 2: Provide and Map Your Components (Once per project)
This is the most critical concept to understand: Introspection Forms does not provide your UI components. You must provide your own inputs, checkboxes, and date pickers — whether they are built in-house or 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. You only do this once when initializing 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 this global mapping, a form with 50 fields doesn’t require you to manually import and wire up 50 input components. The system inspects the generated metadata for each field, consults this global configuration, and automatically selects the correct Vue component.
Step 3: Render the Form
Once your components are mapped, creating a form requires almost no boilerplate. You just import the generated metadata and list the fields you want to show. The useIntrospectionForm composable transforms the 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>
That is a complete, working form. firstName: true tells the system to render this field using the globally registered component for strings (MyTextInput).
While true falls back to the global defaults, you are never locked in. You can override the component, customize its props, or make any field aware of the live model by providing 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
}
})
This ensures the 80% standard cases are handled by global conventions, while the 20% complex, highly dynamic cases remain easy to express.
Runtime Capabilities Unlocked
By generating the structural boilerplate from the schema, Introspection Forms doesn’t just save keystrokes — it unlocks runtime capabilities that are difficult and error-prone to achieve in hand-coded forms:
- Perfect Type Safety: The generated metadata is strongly typed (
IntrospectionType<ContactFormModelInput>). The configuration composable (useIntrospectionForm) constrains your field keys to the actual model properties, meaning a typo in a field name becomes a compile error, not a runtime bug. - Value Conversion at the Boundary: HTML inputs always produce strings (e.g.,
"true","42"). The framework intercepts these values and coerces them into the types expected by the schema (true,42) before they hit the model, eliminating an entire class of subtle payload bugs. - Dynamic & Reactive Configurations: Real forms change based on user input. Every configuration property — visibility, disabled state, grid span, or even specific component props — can be a reactive function that evaluates the live model.
- Seamless Composition: Complex models with nested objects (like a checkout form with separate billing and shipping addresses) are handled by simply nesting
useIntrospectionFormcalls. The form structure exactly mirrors the data structure. - Cross-Stack Validation: Introspection Forms integrates natively with the Dryv framework, allowing C# validation rules to run identically on the Vue frontend without duplicating logic.
Best of all, this 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 fundamentally changes how teams maintain forms over the lifecycle of an enterprise application:
Single Source of Truth
The backend model is the only place where form fields are defined. The GraphQL schema is derived from it. The introspection metadata is generated from the schema. There is exactly one definition, and the rest is computed.
Zero Boilerplate for Standard Forms
A simple form requires no component imports per form, no type definitions, no default value initialization, and no label configuration. Because the plugin configuration acts as the global single source of truth for component mapping, setting firstName: true allows the system to determine the correct component based entirely on schema types and naming conventions.
Safe Schema Evolution
When the backend adds a field, the next code generation run produces updated metadata. If the form config doesn’t reference the new field, nothing changes. If it does, the field appears automatically with the right component and defaults. If the backend removes a field, the TypeScript compiler flags every form that still references it.
Consistent Component Usage
The global configuration plugin ensures that all email fields across all forms render with the same component, the same input type, and the same keyboard hint. Design system consistency is enforced at the configuration level, not by developer discipline.
Composable Architecture
Each part of the system is independently useful:
- Generated metadata can be used for anything that needs field information — not just forms, but also table columns, API documentation, or test data factories.
- The
create()factory can be used anywhere a correctly-initialized model is needed — 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:
- Add the field to the backend model — whether that surfaces as a GraphQL schema change or an OpenAPI spec update.
- Run the code generator (e.g.,
npm run codegen). - Add
newField: trueto the form config (or omit it if the form should show all fields).
That is the entire workflow. The component, label, default value, and type conversion are all handled automatically.
When Not to Use It
Introspection Forms is designed for forms whose structure closely mirrors a backend data model. It is not the right tool for:
- Highly custom layouts where field arrangement doesn’t follow a grid pattern.
- Forms without a backend model — 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 (though nested forms can handle moderate complexity).
For these cases, standard Vue composition with useDryv() for validation provides the necessary flexibility.
The Bigger Picture
Introspection Forms is one instance of a broader architectural principle: derive, don’t duplicate. The same principle applies to GraphQL type generation, validation rule translation, and rich text fragment generation in the wider application.
The investment is in the generators — a GraphQL codegen plugin that walks the schema AST, and an OpenAPI generator that parses spec files — each emitting the same TypeScript constants. Once they 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 it drops to near zero, because maintenance happens at the schema level, and the forms follow.
| 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 comes quickly. By the third form, the generator has paid for itself. By the tenth, the accumulated savings are measured in weeks.
Resources
- GitHub: github.com/mhusseini/introspection-forms
- npm (core): introspection-forms
- npm (Nuxt module): nuxt-introspection-forms
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





