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'}}%%
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'}}%%
flowchart LR
A1["GraphQL Schema"] --> C["Code Generator<br/>(yarn gen)"]
A2["OpenAPI Spec"] --> C
C --> D["Runtime Metadata<br/>(TypeScript const)"]
D --> E["useIntrospectionForm()"]
E --> F["<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. The Vue component consumes that metadata and renders the form. There is no manual interface, no manual component selection, 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.
From Metadata to Rendered Form
The generated metadata is inert data. The useIntrospectionForm composable transforms it into a runtime configuration that the Vue component can render.
The simplest possible form requires just two things — the metadata and a field list:
const data = ref(TypeOfContactFormModelInput.create())
const form = useIntrospectionForm(TypeOfContactFormModelInput, {
firstName: true,
lastName: true,
emailAddress: true,
body: true,
})
<template>
<IntrospectionForm :form="form" :model="data" v-model:validate="validate">
<Button @click.prevent="submit">Send</Button>
</IntrospectionForm>
</template>
That is a complete, working form. firstName: true tells the system to render this field using whatever component it determines is appropriate. How that determination works is the next key concept.
Automatic Component Resolution
A form with 50 fields would be unusable if every field required explicit component assignment. Introspection Forms solves this with a global configuration plugin that maps field characteristics to Vue components through three resolution layers:
| Priority | Match Type | Pattern | Resolved Component |
|---|---|---|---|
| 1 | Field name (regex) | /^email/i | FormInput (type="email") |
| 1 | Field name (regex) | /phone/i | FormInput (type="tel") |
| 1 | Field name (regex) | `/^(body\ | message)/` |
| 1 | Field name (regex) | /files/i | FormFile (multiple=true) |
| 1 | Field name (regex) | /date$/i | FormDateInput |
| 1 | Field name (regex) | /^(address)$/i | FormAddress (composite) |
| 2 | Original schema type | DateTime | FormDateInput |
| 3 | Scalar type | string | FormInput |
| 3 | Scalar type | number | FormInput |
| 3 | Scalar type | boolean | FormCheckbox |
| 3 | Scalar type | enum | FormRadio (with i18n labels) |
The resolution cascade means that naming conventions become rendering conventions. A field named emailAddress automatically gets an email input with the right keyboard on mobile — not because a developer configured it, but because the global plugin has a regex that matches /^email/i. A field of type DateTime automatically gets a date picker. An enum field automatically gets radio buttons with translated labels.
This is a conscious design decision: convention over configuration at the field level, with explicit overrides available when needed. The 80% case is handled by naming conventions and type mappings. The 20% that needs something different can override at the form level.
Reactive Configuration for Dynamic Forms
Real-world forms are rarely static. Fields appear and disappear based on user input. Labels change. Components swap. Introspection Forms handles this with configuration functions that receive the live model:
const form = useIntrospectionForm(TypeOfContactFormModelInput, ContactFormModelValidationSet, {
isCustomer: {
component: FormRadio,
props: withProps<typeof FormRadio>(() => ({
options: () => [
{ value: true, label: 'I am a customer' },
{ value: false, label: 'I am not a customer' },
],
})),
},
// Only visible when isCustomer is true
contractNumber: {
visible: model => model.isCustomer === true,
span: 3,
},
// Label changes based on customer status
ticketNumber: {
span: model => (model.isCustomer ? 3 : 6),
},
// Entire nested form appears conditionally
billingAddress: {
form: useIntrospectionForm({
introspection: TypeOfBillingAddressViewModelInput,
visible: () => model.useSeparateBillingAddress,
config: { salutation: true, firstName: true, lastName: true },
}),
},
})
Every configuration property — visible, disabled, span, label, info, and even component props — can be either a static value or a function. When it is a function, it receives the reactive model as its first argument and a translation function as its second. Vue’s reactivity system automatically re-evaluates these functions when their dependencies change.
This eliminates the need for watchers and manual DOM manipulation. The developer declares the relationship between model state and UI state; the framework handles the rest.
Nested Forms and Composition
Complex data models often contain nested objects. A checkout form might have a delivery address, a billing address, and payment details — each a separate input type in the schema.
Introspection Forms handles this with recursive composition:
const form = useIntrospectionForm(TypeOfShippingAddressViewModelInput, ShippingAddressViewModelValidationSet, {
// Nested address form — its own introspection type, its own fields
shippingAddress: {
form: useIntrospectionForm(TypeOfAddressViewModelInput, {
zipCode: { disabled: true, span: 1 },
city: { ...select(cities), span: 1 },
street: { ...autocomplete(() => options(streets.value)), span: 1 },
houseNumber: { span: 1 },
}),
},
// Toggle for a second address
useSeparateBillingAddress: { span: 2 },
// Second nested form, conditionally visible
billingAddress: {
form: useIntrospectionForm({
introspection: TypeOfBillingAddressViewModelInput,
visible: () => model.useSeparateBillingAddress,
config: {
salutation: { span: 2 },
firstName: true,
lastName: true,
zipCode: true,
city: select(citiesBilling),
street: autocomplete(() => options(streetsBilling.value)),
houseNumber: true,
addressLine2: true,
},
}),
},
})
Each nested useIntrospectionForm call produces an independent form runtime that is embedded as a child. The parent form’s grid layout accommodates the nested form’s fields. Validation — when provided — flows through the nesting hierarchy naturally.
Validation Integration
Introspection Forms has built-in support for Dryv, a validation framework that translates C# validation rules to JavaScript at build time. When a Dryv rule set is passed to useIntrospectionForm, the form component automatically creates a validation-aware proxy for two-way binding:
const form = useIntrospectionForm(
TypeOfContactFormModelInput, // field metadata
ContactFormModelValidationSet, // Dryv rules (optional)
{ /* field config */ }
)
Validation is optional. A form without rules still works — it simply accepts input without constraint. And Dryv works without Introspection Forms — it can be used standalone with useDryv() for forms that need custom layouts.
The separation is intentional: form structure and validation are orthogonal concerns that compose when needed but never depend on each other.
Type Safety Throughout
A generic form system risks becoming a type-safety black hole — passing any through component boundaries, hoping the right props reach the right components. Introspection Forms addresses this at three levels:
1. Generated metadata is strongly typed. TypeOfContactFormModelInput is typed as IntrospectionType, so the compiler knows which fields exist and what types they have.
2. Form configuration is generic. useIntrospectionForm constrains the field names in the config to actual properties of the model. Referencing a non-existent field is a compile error.
3. Component props are type-checked via withProps. The helper function narrows the props type to the actual component:
// TypeScript knows FormInput's props — autocomplete works, typos are caught
props: withProps<typeof FormInput>(() => ({
type: 'email',
placeholder: 'your@email.com',
}))
Without withProps, the generic nature of the form system would erase component prop types. With it, the developer gets full IDE support even when configuring components through metadata rather than JSX.
The Value Conversion Layer
HTML form inputs produce strings. A checkbox produces the string "true". A number input produces "42". But the data model expects booleans and numbers.
Introspection Forms includes a conversion proxy that sits between the input component and the model. When a value is written through the validatable proxy, it is automatically converted based on the field’s introspection type:
%%{init: {'theme': 'dark'}}%%
flowchart LR
A["Input Component<br/>\"true\", \"42\", \"hello\""] -->|"setter"| B["Conversion Proxy<br/>boolean → !!val<br/>number → Number()<br/>string → String()"]
B --> C["Data Model<br/>true, 42, \"hello\""]
This removes an entire class of bugs where a form submits "false" (truthy string) instead of false (boolean), or "0" (string) instead of 0 (number).
Enum Filtering
Not every enum value is always valid. A salutation dropdown might show all four options in a general form but only two in a simplified lead form. Introspection Forms provides a filter mechanism through the global plugin configuration:
app.use(IntrospectionFormsPlugin, {
defaults: {
enumFilters: {
Salutation: values => values.filter(v => v !== 'None'),
Country: values => values.filter(v => allowedCountries.includes(v)),
},
},
})
Individual forms can also filter at the field level. The useIntrospectionFormsEnumFilter composable exposes the filtering logic for use in custom components.
OpenAPI Support
The examples above all show GraphQL, but Introspection Forms works equally well with OpenAPI 3.x and Swagger 2.x specifications. The generateFromOpenApi() function reads a local file (JSON or YAML) or a remote URL and produces the same IntrospectionType metadata:
import { generateFromOpenApi } from 'introspection-forms/openapi'
await generateFromOpenApi({
source: './openapi.yaml', // or a URL
output: './src/generated/introspection',
typesImport: '../api-types', // optional — omit for inline types
include: [/Input$/, 'CreateUserRequest'], // optional schema filter
exclude: [/^Internal/],
})
The output structure is identical to the GraphQL codegen — one file per schema, a barrel index.ts, the same IntrospectionType shape. Everything downstream (useIntrospectionForm, component resolution, validation, storage) works without changes. The runtime layer has no knowledge of which schema source produced the metadata.
This means teams that expose REST APIs with an OpenAPI spec get the same schema-driven form generation without needing a GraphQL layer.
Custom Controls
A form layout sometimes needs elements that don’t correspond to model fields — section dividers, info banners, or contextual hints. Introspection Forms supports this by treating any config key that doesn’t match a field in the introspection metadata as a custom control:
const form = useIntrospectionForm(TypeOfContactFormModelInput, {
firstName: true,
lastName: true,
divider: { component: FormDivider, span: 2 },
email: true,
hint: {
component: FormHint,
props: { text: (model) => model.email ? 'Confirmation will be sent here.' : '' },
visible: (model) => !!model.email,
span: 2,
},
body: true,
})
Custom controls render at their configured position and respect visible and span like regular fields, but they receive no data binding, no label, and no validation wiring.
Session Storage and Form Persistence
The component has built-in support for persisting form state to session or local storage. When a user partially fills a form and navigates away, returning to the page restores their input:
<IntrospectionForm
:form="form"
:model="data"
storage="session"
:interceptStorage="savedData => {
// Optionally transform restored data
return { ...savedData, formId: currentFormId }
}"
/>
The storage key is derived from the introspection type name (ContactFormModelInput), so different forms don’t collide. The interceptStorage callback allows cleaning up or augmenting restored data before it is applied to the model.
Nuxt Integration
For Nuxt 3 and 4 applications, the separate nuxt-introspection-forms module handles the boilerplate:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-introspection-forms'],
introspectionForms: {
generatedPath: 'types/generated/introspection',
},
})
The module registers and as global components, auto-imports the useIntrospectionForm and useIntrospectionFormsEnumFilter composables, and creates a #introspection-types alias so page components can import generated metadata with import { TypeOfContactFormInput } from '#introspection-types'. Component mapping is configured via a Nuxt plugin that provides the defaults through nuxtApp.provide('introspection-forms', { ... }).
Advantages Over Traditional Approaches
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, no type definitions, no default value initialization, and no label configuration. firstName: true does all of it, using naming conventions and type mappings.
Safe Schema Evolution
When the backend adds a field, the next yarn gen 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
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
yarn gen. - 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 | yarn gen + 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.





