Type-Safe Form Generation from GraphQL Introspection

Seventh in a series about migrating from legacy architectures to a modern Nuxt 4 stack.


Two Problems, Two Solutions

Enterprise forms consistently hit two hard problems that legacy architectures force developers to solve by hand:

  1. Form structure — What fields exist, what types they have, how they render
  2. Validation — What rules constrain each field, what error messages to show

In legacy systems, both concerns are implemented twice, on both sides of the stack. The frontend re-describes what the backend already knows. The new architecture eliminates that duplication with two independent code generation pipelines that converge at the component level.

Two Independent Pipelines:

┌─────────────────────────────────────────────────────────────────┐
│ FORM GENERATION (introspect-forms module)                       │
│                                                                 │
│ GraphQL Schema ──► yarn gen ──► TypeScript field metadata        │
│ (input types)        (build)     (type, nullability, enums)     │
│                                         │                       │
│                                         ▼                       │
│                                  useIntrospectForm()            │
│                                  + <IntrospectForm>             │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ VALIDATION (Dryv)                                               │
│                                                                 │
│ C# Rules ──► JavaScriptTranslator ──► yarn gen:rules            │
│ (.NET)         (C# → JS at dev time)      │                    │
│                                           ▼                     │
│                               TypeScript rule set files          │
│                               (executable JS functions)          │
│                                           │                     │
│                                           ▼                     │
│                                    useDryv() / dryvue           │
└─────────────────────────────────────────────────────────────────┘

                    ┌─────────────┐
                    │  Combined   │
                    │  at usage:  │
                    │             │
                    │  useIntrospectForm(                          
                    │    metadata,    ← form generation            
                    │    ruleSet,     ← validation                 
                    │    config       ← per-form overrides         
                    │  )                                           
                    └─────────────┘

Separate concerns, separate modules. They compose because the form system has built-in Dryv support — but validation is optional, and Dryv works perfectly well without introspect-forms.


Form Generation: The introspect-forms Module

The Problem It Solves

In the old system, every form meant hand-writing a TypeScript interface that mirrored the backend model, choosing the right input component for each field type, configuring labels, and wiring a reactive model. All of that was boilerplate — the GraphQL schema already contained the necessary structure.

How It Works

The introspect-forms module is a GraphQL codegen plugin that reads every input type from the backend schema and emits a TypeScript metadata constant:

GraphQL Schema:                          Generated TypeScript:
┌─────────────────────────────┐          ┌──────────────────────────────────┐
│ input ContactFormModelInput │          │ export const                     │
│ {                           │   yarn   │   TypeOfContactFormModelInput = {│
│   email: String!            │   gen    │   name: 'ContactFormModelInput', │
│   name: String!             │ ──────►  │   fields: [                     │
│   postalCode: Int!          │          │     { name: 'email',            │
│   message: String           │          │       type: 'string',           │
│   isCustomer: Boolean!      │          │       isNullable: false,        │
│   contractNumber: String    │          │       isEnum: false, ... },     │
│   contactMethod: Enum!      │          │     { name: 'postalCode',       │
│ }                           │          │       type: 'number',           │
└─────────────────────────────┘          │       isNullable: false, ... }, │
                                         │     ...                         │
                                         │   ],                            │
                                         │   create() { return {...} }     │
                                         │ }                               │
                                         └──────────────────────────────────┘

For each field, the generator extracts:

  • Typestring, number, boolean, enum, object
  • Original GraphQL typeString, Int, DateTime, etc.
  • Nullability! means required, absence means optional
  • Enum values — for enums, the list of valid values
  • Array flag — whether the field is a list
  • Default value — an appropriate zero-value based on type and nullability

The generated create() factory function returns a correctly-typed model instance with all defaults pre-filled — no manual initialization.

The Form Composable

useIntrospectForm consumes the generated metadata and produces a runtime form configuration:

const form = useIntrospectForm<ContactFormModelInput>(
  TypeOfContactFormModelInput,    // generated field metadata
  {
    // optional per-field overrides
    contractNumber: {
      visible: (model) => model.isCustomer === true,
    },
  }
)

The returned form object is passed to the component:

<template>
  <IntrospectForm :form="form" :model="data" v-model:validate="validate" :columns="2">
    <Button @click.prevent="submit">Submit</Button>
  </IntrospectForm>
</template>

The component renders the fields described by the configuration. The config can be bare-bones — contractNumber: true is enough to render a field — or rich, with custom visibility logic, labels, and component overrides.

Which Vue component actually renders a field is decided by a global configuration plugin (described later). The plugin maps field names (via regex), original GraphQL types, and resolved scalar types to concrete form components. As a result, email: true in a form config is sufficient — the global plugin knows that a field matching /^email/i becomes , and that an enum field should render as with i18n-translated labels.


Validation: Dryv

The Problem It Solves

Validation has the classic dual-definition problem: backend and frontend must agree on all rules, but expressing the same logic in two languages inevitably causes drift.

The Dual Validation Problem:

Backend (C#):                          Frontend (TypeScript):
┌─────────────────────────┐            ┌─────────────────────────┐
│ [Required]              │            │ required: true           │
│ public string Email     │   Must     │ pattern: /^[^@]+@.../   │
│                         │   match    │                          │
│ [StringLength(100)]     │  ◄─────►   │ maxLength: 100          │
│ public string Name      │            │ required: true           │
│                         │            │                          │
│ [Range(1, 99999)]       │            │ min: 1, max: 99999      │
│ public int PostalCode   │            │ type: 'number'          │
└─────────────────────────┘            └─────────────────────────┘
        │                                       │
        └───── Divergence here = bug ──────────┘

How Dryv Eliminates It

Dryv is a .NET library that lets you write validation rules once in C# and translates them to JavaScript at development time. A code generator then fetches the translated rules and stores them as TypeScript files in the frontend.

C# Rule Definition:
┌───────────────────────────────────────────────────────┐
│ .Rule(m => m.ZipCode,                                 │
│     m => Regex.IsMatch(m.ZipCode, @"\d{5}")           │
│         ? null                                         │
│         : "Deine Postleitzahl muss aus 5 Ziffern      │
│            bestehen.")                                  │
└───────────────────────────┬───────────────────────────┘
                            │ JavaScriptTranslator
                            ▼
┌───────────────────────────────────────────────────────┐
│ function($m, $ctx) {                                  │
│   return /\d{5,5}/.test($m.zipCode) ? null : {        │
│     type: "error",                                     │
│     text: "Deine Postleitzahl muss aus 5 Ziffern..."  │
│   }                                                    │
│ }                                                      │
└───────────────────────────────────────────────────────┘

The pipeline:

  1. Developer writes C# rules — validation logic is defined once on the .NET model.
  2. Dryv translates C# expressions to JavaScript — regex, string ops, comparisons, etc. translate directly.
  3. The .NET server exposes translated rules via GraphQL — only in non-production environments.
  4. A code generator fetches the translated JavaScript during development and writes TypeScript files into the frontend.
  5. The frontend imports the generated rule sets and executes them client-side.

Rules that cannot be translated to JavaScript (for example, those requiring database access) automatically generate server-call stubs — the client calls a dynamically generated .NET controller endpoint for those validations.

Runtime Parameters

Some rules depend on values that change over time — for example, “user must be at least 18 years old,” which depends on today’s date. Dryv models this with parameters: values defined on the server and fetched at runtime via a separate GraphQL query (dryvRuleParameters).

The generated rule files include null placeholders for parameters. When useDryv() detects parameters in a rule set, it queries the server for current values and injects them into the validation context.

What Gets Generated

Each model yields a TypeScript file containing executable validation functions:

// generated (simplified)
export const RegistrationFormModelValidationSet = {
  name: "RegistrationFormModel",
  validators: {
    email: [
      {
        validate: function ($m, $ctx) {
          return /^[^@]+@[^@]+\.[^@]+$/.test($m.email) ? null : {
            type: "error",
            text: "Please enter a valid email address."
          }
        }
      },
      {
        async: true,
        validate: function ($m, $ctx) {
          // Server-side validation for rules that can't translate to JS
          return $ctx.dryv.callServer("/_v/cpk97zzro", "POST", {
            email: $m.email
          })
        }
      }
    ],
  },
  disablers: { /* conditional skip rules */ },
  parameters: {}
} as DryvValidationRuleSet<RegistrationFormModelInput>;

These are not declarative rule descriptions — they are executable JavaScript functions, compiled from C# expressions by Dryv.

Standalone Usage with useDryv

Dryv is fully usable without the introspect-forms module. The useDryv composable takes a reactive model and a generated rule set and returns a validation-aware proxy:

import { useDryv } from 'dryvue'
import { RegistrationFormModelValidationSet } from '~/types/generated/validation'

const model = ref({ email: '', password: '' })

const { validatable, model: dryModel, validate, valid } = useDryv(
  model.value,
  RegistrationFormModelValidationSet,
)

In the template, bind inputs to dryModel (the proxy) and read errors from validatable:

<template>
  <input v-model="dryModel.email" />
  <span v-if="validatable.email.text">{{ validatable.email.text }}</span>

  <button :disabled="!valid" @click="submit">Submit</button>
</template>

Calling validate() runs all rules — synchronous rules immediately, async rules via promises. The valid ref updates reactively as the user types.

This approach gives full control over layout and markup. The introspect-forms integration wires this up automatically, but useDryv remains the right tool when a form’s layout does not fit the generic pattern.


Combining Both: The Full Picture

The two systems come together at the useIntrospectForm call:

import { TypeOfContactFormModelInput } from '#introspect/types'
import { ContactFormModelValidationSet } from '#dryv'

const data = ref(TypeOfContactFormModelInput.create())

const form = useIntrospectForm<ContactFormModelInput>(
  TypeOfContactFormModelInput,          // ← form generation (field metadata)
  ContactFormModelValidationSet,        // ← validation (Dryv rule set)
  {
    contractNumber: {
      visible: (model) => model.isCustomer === true,
    },
  }
)

The component internally calls useDryv() with the provided rule set and connects validation to the rendered fields. Validation remains optional — forms work without it, and useDryv() can be used independently of introspect-forms.


Global Field Configuration

When a field appears in the form config (even as email: true), the system must know which Vue component to render and how to configure its props. A global configuration plugin — registered once as a Nuxt plugin — maps field patterns to components across all forms:

// app/plugins/configure-forms.ts (simplified)
nuxtApp.provide('forms', {
  byFieldType: {
    string:  { component: FormInput },
    number:  { component: FormInput },
    boolean: { component: FormCheckbox },
    enum:    (introspection) => ({
      component: FormRadio,
      props: withProps<typeof FormRadio>(() => ({
        options: (_, t) => introspection.enumValues.map(value => ({
          value,
          label: t(`forms.${introspection.originalType}.${value}`),
        })),
      })),
    }),
  },
  byOriginalType: {
    DateTime: { component: FormDateInput },
  },
  byFieldName: [
    { regexp: /^(address|adresse)$/i, config: { component: FormAddress } },
    { regexp: /telefon/i,             config: { component: FormInput, props: withProps<typeof FormInput>(() => ({ type: 'tel' })) } },
    { regexp: /^email/i,              config: { component: FormInput, props: withProps<typeof FormInput>(() => ({ type: 'email' })) } },
    { regexp: /^(body|message)$/i,    config: { component: FormTextarea } },
    { regexp: /date$|datum$/i,        config: { component: FormDateInput } },
    { regexp: /files/i,               config: { component: FormFile, props: withProps<typeof FormFile>(() => ({ multiple: true })) } },
  ],
})

The lookup order is:

  1. byFieldName — first regex match on the field name wins.
  2. byOriginalType — exact match on the GraphQL type name (for example, DateTime).
  3. byFieldType — match on the resolved scalar type (string, number, boolean, enum).

Per-form configuration always overrides global defaults. That means email: true yields a fully configured email input with the right keyboard and type attribute — without any form-specific component code.


Dynamic Reactive Configuration

Form fields often depend on each other. A contract number field should only appear for existing customers. A delivery date should only be enabled once an address is provided.

The form system supports reactive configuration functions that receive the live model:

Configuration Functions (reactive):
┌─────────────────────────────────────────────────┐
│ contractNumber: {                               │
│   visible: (model) => model.isCustomer === true │
│   label: (model) => model.isCustomer            │
│     ? 'Your Contract Number'                    │
│     : 'Contract Number (optional)'              │
│ }                                               │
│                                                 │
│ deliveryDate: {                                 │
│   disabled: (model) => !model.address           │
│   props: (model) => ({                          │
│     min: new Date().toISOString().split('T')[0] │
│   })                                            │
│ }                                               │
└─────────────────────────────────────────────────┘

Because these functions receive the reactive model, Vue’s reactivity automatically re-evaluates them when dependencies change. Toggle isCustomercontractNumber appears or disappears. Enter an address → deliveryDate becomes enabled. No manual event wiring is required.


Type-Safe Component Props

A subtle but critical feature: the withProps helper gives you TypeScript safety for UI component props in the generic form system:

Without withProps:
  props: () => ({ type: 'email' })
  // ← TypeScript cannot validate this — props type is unknown

With withProps:
  props: withProps<typeof FormInput>(() => ({ type: 'email' }))
  // ← TypeScript knows FormInput accepts 'type' as a prop
  // ← Autocomplete lists all valid prop names and values

This matters because the form system is generic — it renders different components based on field metadata. Without withProps, the props configuration would be effectively any, and errors would surface only at runtime.


Nested Forms

Complex input types with nested objects are handled recursively. Given a GraphQL schema like:

input OrderFormInput {
  customer: CustomerInput!
  delivery: DeliveryAddressInput!
  payment: PaymentInput!
}

The introspection generator produces nested metadata. The form composable builds nested reactive models. The component renders nested fieldsets. Dryv rules apply at the appropriate nesting depth — the generated rule set contains paths like customer.firstName that automatically map to nested fields.


The Developer Workflow

Adding a new form in the legacy system required seven manual steps across three languages:

  1. Write C# ViewModel.
  2. Add C# validation attributes.
  3. Build a CSHTML Razor form view.
  4. Declare a TypeScript model interface.
  5. Implement a Vue form component with per-field markup.
  6. Write JavaScript validation rules mirroring the C# rules.
  7. Wire up the submit handler.

In the new architecture, most of that disappears:

  1. Write the C# model class (which the business logic needs anyway) and add Dryv rules — the GraphQL input type is derived automatically.
  2. Run the code generators — field metadata and validation rule sets are produced automatically.
  3. Call useIntrospectForm() with a minimal field config and render .

Steps 4–6 from the old stack are gone — TypeScript types, form components, and validation logic are all generated.


Lessons Learned

Generated JavaScript beats “declarative rule descriptions”

Frontend validation libraries like VeeValidate, Yup, or Zod are excellent — but they require writing rules in JavaScript. That puts you back into dual-validation: C# rules on the server, JavaScript rules on the client. They will diverge.

Another approach is to send declarative rule descriptions from the backend ({ type: "regex", pattern: "\\d{5}", message: "..." }) and interpret them on the client. That avoids duplication but limits you to what the interpreter can express.

Dryv takes a third path: rules are written once in C# and compiled into real JavaScript functions. Complex conditional logic, cross-field dependencies, and async server calls all work without a custom interpreter or duplicated rule definitions.

Global field configuration eliminates 80% of per-form work

Most fields follow predictable patterns. An email is always an email input, a boolean is always a checkbox, an enum is always some form of choice input. Capturing these patterns once in a global plugin gives you new forms that “just work” for standard fields.

Reactive configuration functions beat static configuration

Static configuration (JSON, plain objects) cannot naturally express “show this field when that field has this value.” Reactive functions with access to the live model handle conditional behavior cleanly, and Vue’s reactivity handles dependency tracking for free.

GraphQL introspection is underused

Most teams use GraphQL introspection for documentation or IDE tooling. Using it to drive form generation is a straightforward extension — the schema already describes data shapes, types, and constraints. The step from documentation to automation is smaller than it looks.


What’s Next

  • Article 8: The Modular Architecture — Independent Building Blocks — How 35+ custom Nuxt modules create a maintainable, extensible system.
  • Article 9: SSR Deep Dive — Hydration, State Replay, and the Cookbook — The dark corners of server-side rendering that every Nuxt developer encounters.
  • Article 10: Memory, Stability, and PM2 — Running a Long-Lived Node.js Server — What happens when V8 runs for days and how to keep it stable.

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

Categories:

Leave a Reply

Your email address will not be published. Required fields are marked *