← Back to list

OpenAPI to Signal Forms: Generate Angular Form Configs from Your API Spec

Stop hand-writing form configs your API spec already defines

Antim Prisăcaru in ITNEXT · 2026-04-07 09:55 · 7 claps · 11.3 min read
#angular #open-api #typescript #signal-forms #code-generation
Open on Medium ↗
Wiki topics: CRY · Crypto & Web3 🌐 · Web Development

OpenAPI to Signal Forms: Generate Angular Form Configs from Your API Spec

You already have your forms. They’re just hiding in your OpenAPI spec.

Every property in your schema — its type, format, constraints, whether it’s required — maps to a form field with a specific input type, validators, and label. Your backend team already wrote that information. You’re just rewriting it in a different syntax.

I got tired of doing this by hand. So I built a generator for it.

ng-forge Dynamic Forms is a configuration-driven form library for Angular, built on the experimental Signal Forms API. You describe a form as a single typed configuration object and the library renders it — no templates per field, no manual wiring. I’ve written about the library before; this article is about what happens when you point a code generator at an OpenAPI spec and have it write those configurations for you.

@ng-forge/openapi-generator reads an OpenAPI 3.x spec and produces fully typed FormConfig objects and TypeScript interfaces. This is not an HTTP client generator (that's what ng-openapi-gen and similar tools do). It generates form UI configurations from the same schemas those tools use for request/response types. In short:

  • Generates Angular form configs and TypeScript interfaces from OpenAPI 3.x
  • Maps types, validators, nested objects, allOf composition, and oneOf discriminators
  • Asks about ambiguous UX decisions (checkbox vs. toggle? select vs. radio?) instead of guessing
  • Saves your choices for deterministic CI runs
  • Works with all four UI adapters (Material, Bootstrap, PrimeNG, Ionic) out of the box

Setup

Install the generator as a dev dependency — it generates code at build time, not at runtime:

npm install -D @ng-forge/openapi-generator

The generated output depends on @ng-forge/dynamic-forms, which you already have if you're using ng-forge. If you're new to ng-forge, set up the basics first:

npm install @ng-forge/dynamic-forms @ng-forge/dynamic-forms-material @angular/material @angular/cdk

Wire the providers:

import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideDynamicForm } from '@ng-forge/dynamic-forms';
import { withMaterialFields } from '@ng-forge/dynamic-forms-material';

export const appConfig: ApplicationConfig = {
  providers: [provideZonelessChangeDetection(), provideAnimations(), provideDynamicForm(...withMaterialFields())],
};

The Simplest Case

Say your spec has a user registration endpoint:

paths:
  /users/register:
    post:
      operationId: registerUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
                  format: password
                  minLength: 8
                bio:
                  type: string
                  maxLength: 500

Run the generator:

npx @ng-forge/openapi-generator --spec openapi.yaml --output src/generated

You get two files. The form config (src/generated/forms/register-user.form.ts):

// @generated by @ng-forge/openapi-generator
import type { FormConfig } from '@ng-forge/dynamic-forms';

export const registerUserFormConfig = {
  fields: [
    {
      key: 'email',
      type: 'input',
      label: 'Email',
      props: { type: 'email' },
      validators: [{ type: 'required' }, { type: 'email' }],
    },
    {
      key: 'password',
      type: 'input',
      label: 'Password',
      props: { type: 'password' },
      validators: [{ type: 'required' }, { type: 'minLength', value: 8 }],
    },
    {
      key: 'bio',
      type: 'textarea',
      label: 'Bio',
      validators: [{ type: 'maxLength', value: 500 }],
    },
  ],
} as const satisfies FormConfig;

And the TypeScript interface (src/generated/types/register-user.types.ts):

// @generated by @ng-forge/openapi-generator
export interface RegisterUserFormValue {
  email: string;
  password: string;
  bio?: string;
}

Worth looking at what happened here. format: email pulled double duty — it set the HTML input type and added an email validator. The required array became validators on those two fields and made bio optional (?) in the interface. And bio ended up as a textarea even though the spec just says string — the generator saw the field name and guessed (correctly) that a field called "bio" probably wants more than one line. It does the same for names like description, notes, comment, body, content, summary, and message. The maxLength: 500 would have triggered the same heuristic anyway — anything over 200 characters gets a textarea.

Using it in a component:

import { registerUserFormConfig } from '../generated/forms/register-user.form';
import type { RegisterUserFormValue } from '../generated/types/register-user.types';

@Component({
  imports: [DynamicForm],
  template: `<form [dynamic-form]="config" (submitted)="onSubmit($event)" />`,
})
export class RegisterComponent {
  config = registerUserFormConfig;
  onSubmit(value: RegisterUserFormValue) {
    // Fully typed, matches the OpenAPI schema exactly
  }
}

Will turn into this:

Generated registration form with validation states

Generated registration form with validation states

Type Mapping

The generator maps OpenAPI types to form field types using the schema’s type, format, constraints, and the field's name. The straightforward cases — format: email → email input, format: date → datepicker, object → group, array of objects → repeatable array — are deterministic. String formats, container types, and multi-checkbox for enum arrays all resolve without ambiguity.

The defaults for everything else: stringinput, string + enumselect, integer/numberinput (number), booleancheckbox. But these are defaults, not final answers. A boolean could be a toggle switch. An enum could be radio buttons. A number could be a slider. These are UX decisions, not type-system decisions — and the generator knows the difference.

Before asking you, it tries to resolve ambiguity from the field’s name. Fields ending with description, notes, comment, bio, body, content, summary, or message are automatically mapped to textarea. Fields ending with phone, tel, mobile, or fax get a tel input type. These heuristics are a convenience — not a constraint. If the generator guesses wrong, override it with an x-ng-forge-type extension (covered below).

But defaults and heuristics only go so far. That still leaves the question open: where do the alternatives in those prompts actually come from?

Field Scopes and Ambiguity

Most codegen tools hardcode their output: OpenAPI boolean → checkbox, done. If you want a toggle switch instead, you edit the generated code. Every time.

ng-forge has a concept called field scopes — semantic groupings that declare which field types are interchangeable. A checkbox and a toggle are both boolean scope. A select and a radio are both single-select. The generator uses the same idea: when it encounters an ambiguous mapping, it knows which alternatives to offer because field types are grouped by what kind of data they represent, not by what they look like.

Currently the generator ships with a built-in scope map covering the standard field types across all four adapters (Material, Bootstrap, PrimeNG, Ionic):

  • boolean: checkbox, toggle
  • single-select: select, radio
  • numeric: input (number), slider
  • text-input: input (text), textarea

When the generator hits an ambiguous field, it prompts you with the alternatives for that scope:

? Field "isActive" is boolean. How should it render?
  ❯ Checkbox
    Toggle (switch)

Your answer is saved to .ng-forge-generator.json so it's not asked again on subsequent runs — run once interactively, commit the config, and every future run produces identical output.

If you’re using custom field types (a star-rating component, a color picker), the x-ng-forge-type extension lets you bypass the scope system entirely and specify the exact type to emit. Making the generator dynamically discover custom scopes from adapter registrations is on the roadmap.

Validator Mapping

OpenAPI constraints map directly to ng-forge validators:

  • required: truerequired
  • minLength / maxLengthminLength / maxLength
  • minimum / maximummin / max
  • exclusiveMinimum / exclusiveMaximummin / max (adjusted — see below)
  • patternpattern
  • format: emailemail
  • format: uuidpattern (UUID regex)
  • minItems / maxItems (arrays) → minLength / maxLength

A note on exclusiveMinimum/exclusiveMaximum: in OpenAPI 3.1, these are numeric values (e.g., exclusiveMinimum: 0 means "strictly greater than 0"). Angular's min/max validators use inclusive comparisons (>=/<=), so for integer schemas the generator adjusts by 1 — exclusiveMinimum: 0 becomes min: 1. For non-integer schemas this is an approximation; a custom validator would be needed for exact floating-point strict inequality.

Other schema properties map to field configuration rather than validators:

  • readOnly: truedisabled: true
  • default: valuevalue: value
  • descriptionprops.hint

The mapping is conservative — if the spec doesn’t constrain a field, the generated config doesn’t either. No invented validators, no assumed defaults.

Nested Objects and Arrays

Real APIs nest things. A customer object inside an order, an array of line items — the generator maps these to ng-forge’s group and array container fields:

components:
  schemas:
    Order:
      type: object
      required: [customer, items]
      properties:
        customer:
          type: object
          required: [name, email]
          properties:
            name:
              type: string
            email:
              type: string
              format: email
        items:
          type: array
          minItems: 1
          items:
            type: object
            required: [product, quantity]
            properties:
              product:
                type: string
              quantity:
                type: integer
                minimum: 1

Generated config (abbreviated):

export const createOrderFormConfig = {
  fields: [
    {
      key: 'customer',
      type: 'group',
      label: 'Customer',
      fields: [
        {
          key: 'name',
          type: 'input',
          label: 'Name',
          validators: [{ type: 'required' }],
        },
        {
          key: 'email',
          type: 'input',
          label: 'Email',
          props: { type: 'email' },
          validators: [{ type: 'required' }, { type: 'email' }],
        },
      ],
    },
    {
      key: 'items',
      type: 'array',
      label: 'Items',
      validators: [{ type: 'minLength', value: 1 }],
      template: [
        {
          key: 'product',
          type: 'input',
          label: 'Product',
          validators: [{ type: 'required' }],
        },
        {
          key: 'quantity',
          type: 'input',
          label: 'Quantity',
          props: { type: 'number' },
          validators: [{ type: 'required' }, { type: 'min', value: 1 }],
        },
      ],
    },
  ],
} as const satisfies FormConfig;

The generated interface mirrors the nesting exactly — customer becomes a nested object type, items becomes an array type. Nesting goes as deep as your spec does; the generator just walks the schema tree recursively.

export interface CreateOrderFormValue {
  customer: {
    name: string;
    email: string;
  };
  items: {
    product: string;
    quantity: number;
  }[];
}

And the result:

Order form with nested group and array containers

Order form with nested group and array containers

Schema Composition with allOf

You’ll run into allOf in any non-trivial spec — it's how OpenAPI does inheritance. A NewPet extends Pet with extra fields:

NewPet:
  allOf:
    - $ref: '#/components/schemas/Pet'
    - type: object
      properties:
        ownerEmail:
          type: string
          format: email
      required: [ownerEmail]

The generator dereferences the $ref, merges all schemas in the allOf, unions the required fields, and produces one flat form. You don't need to care about the composition — it's resolved before any field mapping happens.

Polymorphic Forms with Discriminators

Discriminators are the case I was most unsure about when building this. OpenAPI’s oneOf with a discriminator describes polymorphic payloads — a payment that's either a credit card or a bank transfer, determined by a paymentMethod field:

Payment:
  oneOf:
    - $ref: '#/components/schemas/CreditCardPayment'
    - $ref: '#/components/schemas/BankTransferPayment'
  discriminator:
    propertyName: paymentMethod
    mapping:
      credit_card: '#/components/schemas/CreditCardPayment'
      bank_transfer: '#/components/schemas/BankTransferPayment'

The generator produces a form with conditional visibility — a radio field for the discriminator, and group fields for each variant that show/hide based on the selection:

{
  fields: [
    {
      key: 'paymentMethod',
      type: 'radio',
      label: 'Payment Method',
      options: [
        { label: 'Credit Card', value: 'credit_card' },
        { label: 'Bank Transfer', value: 'bank_transfer' },
      ],
      validators: [{ type: 'required' }],
    },
    {
      key: 'credit_cardVariant',
      type: 'group',
      label: 'Credit Card',
      fields: [
        { key: 'cardNumber', type: 'input', label: 'Card Number', /* ... */ },
        { key: 'expiry', type: 'input', label: 'Expiry', /* ... */ },
      ],
      logic: [{
        type: 'hidden',
        condition: {
          type: 'fieldValue',
          fieldPath: 'paymentMethod',
          operator: 'notEquals',
          value: 'credit_card',
        },
      }],
    },
    {
      key: 'bank_transferVariant',
      type: 'group',
      label: 'Bank Transfer',
      fields: [
        { key: 'iban', type: 'input', label: 'Iban', /* ... */ },
        { key: 'bic', type: 'input', label: 'Bic', /* ... */ },
      ],
      logic: [{
        type: 'hidden',
        condition: {
          type: 'fieldValue',
          fieldPath: 'paymentMethod',
          operator: 'notEquals',
          value: 'bank_transfer',
        },
      }],
    },
  ],
}

The logic blocks here are the same ones you'd write by hand if you were building this form from scratch — hidden conditions that check the discriminator value. The generator just writes them for you from the discriminator mapping. No imperative show/hide code, no if chains.

Payment form — discriminator toggles between credit card and bank transfer groups

Payment form — discriminator toggles between credit card and bank transfer groups

Custom Extensions

Sometimes you know better than the type system. Two vendor extensions let you override the generator:

**x-ng-forge-type** overrides the generated field type entirely:

rating:
  type: integer
  x-ng-forge-type: star-picker

This skips all type mapping logic and emits type: 'star-picker' directly. Useful for custom field types that don't map to any standard OpenAPI concept.

**x-enum-labels** provides human-readable labels for enum values:

status:
  type: string
  enum: [pending, approved, rejected]
  x-enum-labels:
    pending: 'Awaiting Review'
    approved: 'Approved'
    rejected: 'Rejected'

Without this extension, the generator title-cases the enum values (Pending, Approved, Rejected). With it, you get exactly the labels you specify.

Non-Interactive Mode and CI

The interactive prompts are useful exactly once. After that first run, the generator saves every decision to .ng-forge-generator.json:

{
  "spec": "openapi.yaml",
  "output": "src/generated",
  "endpoints": ["POST:/users/register", "PUT:/users/{id}"],
  "decisions": {
    "registerUser.isActive": "toggle",
    "updateUser.role": "radio"
  }
}

Subsequent runs read this config and skip prompts. For CI pipelines, pass --interactive none to fail on any unresolved ambiguity rather than blocking on a prompt:

npx @ng-forge/openapi-generator \
  --spec openapi.yaml \
  --output src/generated \
  --interactive none

Commit this file next to your spec. Now the whole pipeline is deterministic — spec changes, you regenerate, the diff shows exactly what changed in your forms. For development, --watch regenerates on every spec save.

Endpoint Selection

You probably don’t want forms for every endpoint in the spec. --endpoints filters:

npx @ng-forge/openapi-generator \
  --spec openapi.yaml \
  --output src/generated \
  --endpoints "POST:/users/register,PUT:/users/{id}"

Or run interactively the first time — the generator lists all discovered endpoints (GET, POST, PUT, PATCH) and lets you select which ones to generate forms for.

GET endpoints are supported too. By default they produce editable forms (useful for search or filter interfaces). Pass --read-only to generate disabled fields instead, for display-only views.

The Programmatic API

The CLI is a convenience wrapper. Everything it does is available as a library — and for teams with non-standard workflows, the API is the real product.

The CLI assumes a simple pipeline: one spec in, files on disk out. But your situation might be different. Maybe the spec lives behind an authenticated endpoint and needs to be fetched at build time. Maybe you generate forms as part of a larger code scaffolding step that also produces API clients and route definitions. Maybe you want to post-process the generated fields — injecting company-wide default props, adding analytics hooks, or filtering endpoints based on feature flags. The CLI can’t anticipate all of this, but the API gives you every piece of the pipeline as a composable function.

import { parseOpenAPISpec, extractEndpoints, mapSchemaToFields, generateFormConfig, generateInterface } from '@ng-forge/openapi-generator';

const spec = await parseOpenAPISpec('openapi.yaml');
const endpoints = extractEndpoints(spec);
for (const endpoint of endpoints) {
  const schema = endpoint.requestBodySchema ?? endpoint.responseSchema;
  if (!schema) continue;
  const { fields, ambiguousFields, warnings } = mapSchemaToFields(schema, endpoint.requiredFields);
  const formSource = generateFormConfig(fields, {
    method: endpoint.method,
    path: endpoint.path,
    operationId: endpoint.operationId,
  });
  const interfaceSource = generateInterface(schema, {
    method: endpoint.method,
    path: endpoint.path,
    operationId: endpoint.operationId,  
  });
}

Each function in that pipeline is independently useful. mapSchemaToFields returns structured data — fields, ambiguities, warnings — that you can inspect, transform, or route before generating any code. generateFormConfig takes fields and returns a string of TypeScript source code. You control what happens between those steps and what happens with the output.

What It Doesn’t Do

I want to be honest about the boundaries here, because overselling a codegen tool is how you end up with disappointed users.

The generator won’t produce derivations (computed fields), async validators, or submission handlers. Those depend on application logic that doesn’t exist in a spec file. It won’t infer layout — column spans, row groupings, multi-page flows — because OpenAPI has no concept of visual structure. anyOf and additionalProperties are skipped with a warning. if/then/else is not yet supported, though the common pattern — "if field X equals Y, require/show fields A and B" — maps naturally to ng-forge's logic blocks, so this is on the roadmap.

What you get is a scaffold that’s structurally correct and fully typed. The interesting work — derivations, conditional logic beyond discriminators, submission flows — you still add by hand. But you’re adding it to a form that already has every field, every validator, and every type in the right place.

The Bigger Picture

An OpenAPI spec is already a contract between services. The generator just extends that contract to the form layer. The output is standard FormConfig — same objects you'd write by hand, same type safety, same as const satisfies FormConfig pattern, same compatibility with every ng-forge feature. There's no lock-in and no runtime dependency; the generated code is yours to modify.

The workflow I actually wanted when I started building this: the spec changes, I re-run the generator, the diff tells me exactly which forms were affected, and TypeScript catches any downstream breakage before I even open a browser.

And if you’d rather skip the spec entirely and build forms visually — a form builder is in the works.

Stop rewriting your API as forms. You already did that work.

ng-forge Dynamic Forms is open source. The documentation includes a full OpenAPI generator guide with interactive examples.


메타데이터
post_id
490ddfe65bea
slug
openapi-to-signal-forms-generate-angular-form-configs-from-your-api-spec-490ddfe65bea
url
https://itnext.io/openapi-to-signal-forms-generate-angular-form-configs-from-your-api-spec-490ddfe65bea
canonical_url
https://itnext.io/openapi-to-signal-forms-generate-angular-form-configs-from-your-api-spec-490ddfe65bea
author_url
https://medium.com/@antimprisacaru
status
ok
fetched_at
2026-07-09 05:26:43