DEV Community

Cover image for Building Type-Safe Dynamic Forms with Angular Signal Forms
Antim Prisăcaru
Antim Prisăcaru

Posted on • Originally published at itnext.io

Building Type-Safe Dynamic Forms with Angular Signal Forms

Angular 21 introduced Signal Forms — an experimental API that brings signals to form management. The form() function wraps a WritableSignal model, making your data the single source of truth. Validation, state tracking, field binding - all reactive, all signal-based.

Signal Forms provide a solid reactive foundation, but they’re low-level. You still write templates for each field, wire up validation manually, and handle conditional visibility yourself.

ng-forge Dynamic Forms builds on Signal Forms and adds configuration-driven form generation. One typed configuration defines everything: fields, validation, conditionals, computed values, multi-page flows. The framework wires this together so you don’t need custom glue code.


When to Use This vs Raw Signal Forms

Signal Forms are great when you want full control — custom templates, bespoke validation UI, one-off forms that don’t follow patterns. Use them directly when you need maximum flexibility.

ng-forge makes sense when:

  • You’re building multiple forms with similar structure

  • You want consistent validation, layout, and error handling without repeating yourself

  • Your forms have conditional logic, computed fields, or multi-page flows

  • You want type inference for both config authoring and form output

  • You’re integrating with a component library (Material, Bootstrap, PrimeNG, Ionic)

The two aren’t mutually exclusive. ng-forge uses Signal Forms under the hood, so you get the same reactive foundation with less boilerplate.


The Story Behind This

I used ngx-formly on a project and it got the job done, but I kept running into friction.

The configs weren’t typed, so I was constantly double-checking docs for property names. Validators were any. When I needed conditional visibility or computed values, that logic ended up in lifecycle hooks - imperative code living far from the fields it affected. The DOM output had so many wrapper elements that styling with CSS Grid became a chore. And with larger forms, rendering felt sluggish - though I didn't profile it, so that's subjective.

Credit where it’s due — the maintainers have kept that library running across countless Angular versions. But the architecture predates signals, and some of these pain points are hard to address without a fresh start.

When Signal Forms landed in Angular 21, I saw an opening. The main design benchmarks were:

  • Type inference that actually works — autocomplete while writing configs, inferred output types, compile-time errors for typos

  • Everything declarative — conditionals, derivations, validation, all in the config where you can see it

  • A leaner DOM — fewer wrapper nodes, easier styling

  • Signals from day one — localized change detection, no fighting Angular’s reactivity model


The Core Idea

One typed configuration defines everything — fields, validation, layout, conditional behavior, computed values:

import { DynamicForm, FormConfig, InferFormValue } from '@ng-forge/dynamic-forms';

const formConfig = {
  fields: [
    { key: 'email', type: 'input', label: 'Email', required: true, email: true },
    { key: 'password', type: 'input', label: 'Password', required: true, minLength: 8, props: { type: 'password' } },
    { key: 'rememberMe', type: 'checkbox', label: 'Remember me' },
    { key: 'submit', type: 'submit', label: 'Sign In' },
  ],
} as const satisfies FormConfig;

@Component({
  imports: [DynamicForm],
  template: `<form [dynamic-form]="formConfig" (submitted)="onSubmit($event)" />`,
})
export class LoginComponent {
  formConfig = formConfig;

  onSubmit(value: InferFormValue<typeof formConfig>) {
    // value is fully typed: { email: string; password: string; rememberMe?: boolean }
    // Note: rememberMe is optional (?) because it's not marked required
  }
}
Enter fullscreen mode Exit fullscreen mode

The as const satisfies pattern is key. satisfies validates your config against FormConfig at compile time - typos, invalid properties, wrong types are caught immediately. as const preserves literal types so TypeScript can infer the exact shape of your form's output. Together, you get validation AND exact type inference.


Type Safety

Type safety was a core goal, and it works in both directions — while you’re writing configs, and for the data you get out.

A brief example of type safety in a form config

While Authoring

As you write your configuration, you get full intellisense. Type a field’s type property and autocomplete shows you every available field type. Add a select field and the editor knows you need an options array. Try to add an invalid property and TypeScript complains immediately.

Misconfigured forms fail at compile time, not at runtime in production.

const form = {
  fields: [
    {
      key: 'country',
      type: 'select',
      label: 'Country',
      required: true,
      options: [
        { label: 'United States', value: 'us' },
        { label: 'Canada', value: 'ca' },
      ],
    },
  ],
} as const satisfies FormConfig;
Enter fullscreen mode Exit fullscreen mode

For Output

The form’s value type is inferred automatically from the configuration. No manual interface definitions, no keeping types in sync with configs:

type FormValue = InferFormValue<typeof form>;
// Result: { country: string }
Enter fullscreen mode Exit fullscreen mode

Add a field to your config? The inferred type updates automatically. Rename a key? TypeScript catches every reference that needs updating. Nest fields inside a group container? The inferred type reflects the nested structure.

const form = {
  fields: [
    { key: 'name', type: 'input', label: 'Name' },
    {
      key: 'address',
      type: 'group',
      fields: [
        { key: 'street', type: 'input', label: 'Street' },
        { key: 'city', type: 'input', label: 'City' },
      ],
    },
  ],
} as const satisfies FormConfig;
Enter fullscreen mode Exit fullscreen mode
type Value = InferFormValue<typeof form>;
// Result: { name: string; address: { street: string; city: string } }
Enter fullscreen mode Exit fullscreen mode

Module Augmentation

Each UI adapter extends the base types with library-specific options via TypeScript’s module augmentation. When you import @ng-forge/dynamic-forms-material, the type system automatically expands to include Material-specific properties like appearance and prefixIcon. Import the Bootstrap adapter instead, and you get Bootstrap-specific options.

The same config structure works across libraries, but you get appropriate autocomplete and type checking for whichever UI library you’re using. This is made possible by TypeScript’s declaration merging — the adapters extend the base field interfaces without modifying the core library.

// With Material adapter imported:
{
  key: 'name',
  type: 'input',
  props: {
    appearance: 'outline',  // ← Material-specific
    prefixIcon: 'person',   // ← Material-specific
  }
}
Enter fullscreen mode Exit fullscreen mode

Validation

Validation is declarative and flexible. For common cases, shorthand properties keep configs concise. For complex scenarios, the full validator syntax gives you complete control.

Shorthand Syntax

The most common validators are available directly on field definitions. No ceremony, no boilerplate — just add the properties you need:

{
  key: 'email',
  type: 'input',
  label: 'Email',
  required: true,
  email: true,
  minLength: 5,
  maxLength: 100
}
Enter fullscreen mode Exit fullscreen mode

Available shorthand validators include required, email, minLength, maxLength, min, max, and pattern. They're self-documenting and immediately visible in the field config.

Full Syntax

When you need more control — multiple validators of the same type, custom validation logic, or conditional validation — use the validators array:

{
  key: 'password',
  type: 'input',
  label: 'Password',
  validators: [
    { type: 'required' },
    { type: 'minLength', value: 8 },
    { type: 'pattern', value: '^(?=.*[A-Z])(?=.*[0-9]).*$' }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Custom Validators with Expressions

For validation that depends on other fields, write expressions that reference the form’s values. The fieldValue variable gives you the current field's value, while formValue provides access to the entire form:

{
  key: 'confirmPassword',
  type: 'input',
  label: 'Confirm Password',
  validators: [
    { type: 'required' },
    { type: 'custom', expression: 'fieldValue === formValue.password', kind: 'mismatch' }
  ],
  validationMessages: {
    mismatch: 'Passwords do not match'
  }
}
Enter fullscreen mode Exit fullscreen mode

The kind property links validators to custom error messages. Override built-in messages or provide your own - all at the field level where they're easy to find and maintain.

Schema Validation with Zod, Valibot, or ArkType

For complex cross-field validation, ng-forge supports the Standard Schema spec — meaning you can use Zod, Valibot, ArkType, or any compliant library. Wrap your schema with standardSchema() and pass it to the form config:

import { z } from 'zod';
import { standardSchema } from '@ng-forge/dynamic-forms/schema';

const loginSchema = z.object({
  email: z.string().email('Invalid email format'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

const config = {
  schema: standardSchema(loginSchema),
  fields: [
    { key: 'email', type: 'input', label: 'Email' },
    { key: 'password', type: 'input', label: 'Password', props: { type: 'password' } },
    { key: 'submit', type: 'submit', label: 'Login' },
  ],
} as const satisfies FormConfig;
Enter fullscreen mode Exit fullscreen mode

This gives you the full power of your preferred schema library for validation, while keeping the declarative form structure.

Async Validation and Submission

For server-side validation (checking if an email exists, validating against an API), ng-forge supports async validators via customAsync and customHttp types. HTTP validators include automatic request cancellation when values change mid-flight.

Submission handling integrates with Signal Forms’ native submit() function. Define a submission.action that returns a Promise or Observable, and the form tracks submitting state automatically for loading indicators:

const config = {
  fields: [...],
  submission: {
    action: (form) => this.http.post('/api/register', form().value()).pipe(
      catchError((error) => {
        if (error.status === 409) {
          // Return field errors for server validation
          return of([{ field: form.email, error: { kind: 'emailExists', message: 'Email taken' }}]);
        }
        throw error;
      })
    )
  }
} as const satisfies FormConfig;
Enter fullscreen mode Exit fullscreen mode

Server validation errors are automatically applied to the corresponding fields.


Conditional Logic

Real-world forms are rarely static. Fields appear based on previous answers. Requirements change depending on selections. Sections become read-only after certain actions. ng-forge handles all of this declaratively through logic blocks.

Each logic block specifies a behavior (hidden, disabled, readonly, or required) and a condition that controls when it applies. Conditions can compare field values, combine with and/or logic, or use JavaScript expressions for full flexibility.

Conditional Visibility and Required Together

A common pattern: show a field only when relevant, and make it required when shown. Here’s a business account form where company-specific fields appear only for business accounts:

{
  key: 'companyName',
  type: 'input',
  label: 'Company Name',
  props: { appearance: 'outline' },
  logic: [
    {
      type: 'hidden',
      condition: {
        type: 'fieldValue',
        fieldPath: 'accountType',
        operator: 'notEquals',
        value: 'business',
      },
    },
    {
      type: 'required',
      condition: {
        type: 'fieldValue',
        fieldPath: 'accountType',
        operator: 'equals',
        value: 'business',
      },
    },
  ],
}
Enter fullscreen mode Exit fullscreen mode

Multiple logic blocks on the same field is common — hide when not relevant, require when shown. The conditions are evaluated reactively as the user interacts with the form.

Complex Conditions

For conditions that depend on multiple fields, combine them with and or or. Or skip the structured conditions entirely and write JavaScript expressions when the logic gets complex:

logic: [
    {
      type: 'disabled',
      condition: {
        type: 'javascript',
        expression: 'formValue.age < 18 || !formValue.parentConsent',
      },
    },
  ];
Enter fullscreen mode Exit fullscreen mode

The available logic types — hidden, disabled, readonly, and required - cover the most common dynamic behaviors. They can be combined on a single field, each with its own condition.


Derived Values

Sometimes field values should be computed from other fields — a full name concatenated from first and last name, a total calculated from quantity and price.

Value derivation in action

Derivations are defined on the source fields, pointing to the target field that should receive the computed value. When a source field changes, the expression is evaluated and the target is updated automatically.

{
    key: 'firstName',
    type: 'input',
    label: 'First Name',
  },
  {
    key: 'lastName',
    type: 'input',
    label: 'Last Name',
  },
  {
    key: 'fullName',
    type: 'input',
    label: 'Full Name',
    readonly: true,
    logic: [
      {
        type: 'derivation',
        targetField: 'fullName',
        expression: 'formValue.firstName + " " + formValue.lastName',
      },
    ],
  }
Enter fullscreen mode Exit fullscreen mode

Expressions have access to formValue - the complete form state as a nested object. For calculations, you may use mathematical operators for example:

{
  key: 'quantity',
  type: 'input',
  label: 'Quantity',
  props: { type: 'number' },
  logic: [
    {
      type: 'derivation',
      targetField: 'total',
      expression: 'formValue.quantity * formValue.unitPrice',
    },
  ],
}
Enter fullscreen mode Exit fullscreen mode

Cycle Detection and Bidirectional Sync

Derivations build a dependency graph internally. If you accidentally create a cycle (A derives B, B derives C, C derives A), the form throws an error at initialization with the exact cycle path.

Bidirectional patterns are an exception. Currency converters, unit conversions, any case where two fields should stay in sync — these are allowed. The system detects A→B and B→A pairs and lets them through because they stabilize via equality checks at runtime. When the derived value equals the current value, the chain stops.

// Bidirectional: USD ↔ EUR (allowed, stabilizes automatically)
{
  key: 'usd',
  type: 'input',
  logic: [{ type: 'derivation', targetField: 'eur', expression: 'Math.round(formValue.usd * 0.92 * 100) / 100' }],
},
{
  key: 'eur',
  type: 'input',
  logic: [{ type: 'derivation', targetField: 'usd', expression: 'Math.round(formValue.eur / 0.92 * 100) / 100' }],
}
Enter fullscreen mode Exit fullscreen mode

For floating-point values, rounding in the expression prevents oscillation from precision errors.


Containers & Layout

Four container types handle form structure:

  • Groups create nested objects: { address: { street, city, zip } }

  • Rows arrange fields horizontally using a 12-column grid (col: 6 for half-width)

  • Arrays create dynamic lists with add/remove functionality

  • Pages enable multi-step wizards (covered next)

// Group: nested structure
{
  key: 'billing',
  type: 'group',
  fields: [
    { key: 'street', type: 'input', label: 'Street' },
    { key: 'city', type: 'input', label: 'City' }
  ]
}

// Row: horizontal layout
{
  key: 'nameRow',
  type: 'row',
  fields: [
    { key: 'firstName', type: 'input', label: 'First', col: 6 },
    { key: 'lastName', type: 'input', label: 'Last', col: 6 }
  ]
}


// Array: dynamic list
{
  key: 'emails',
  type: 'array',
  fields: [
    { key: 'email', type: 'input', label: 'Email', email: true },
    { key: 'remove', type: 'removeArrayItem', label: '' },
  ],
},
{ key: 'addEmail', type: 'addArrayItem', arrayKey: 'emails', label: 'Add Email' }
Enter fullscreen mode Exit fullscreen mode

Multi-Page Wizards

Long forms are overwhelming. Breaking them into steps improves completion rates and reduces cognitive load. ng-forge supports multi-page forms with built-in navigation and per-page validation.

Wrap your fields in page containers, and the framework handles the rest. Navigation buttons (next and previous) automatically manage page transitions. Users can't advance until the current page passes validation - no custom logic required.

const wizardConfig = {
  fields: [
    {
      key: 'step1',
      type: 'page',
      fields: [
        { key: 'name', type: 'input', label: 'Name', required: true },
        { key: 'email', type: 'input', label: 'Email', required: true, email: true },
        { key: 'next', type: 'next', label: 'Continue' },
      ],
    },
    {
      key: 'step2',
      type: 'page',
      fields: [
        { key: 'address', type: 'input', label: 'Address' },
        {
          key: 'navRow',
          type: 'row',
          fields: [
            { key: 'back', type: 'previous', label: 'Back', col: 6 },
            { key: 'submit', type: 'submit', label: 'Submit', col: 6 },
          ],
        },
      ],
    },
  ],
} as const satisfies FormConfig;
Enter fullscreen mode Exit fullscreen mode

Page state is managed automatically. The form tracks which page is active, handles transitions, and aggregates validation across all pages for final submission. You focus on defining the steps — the framework handles the orchestration.


UI Adapters

ng-forge separates configuration from rendering. Your form config describes what to display — the UI adapter determines how it looks.

Four adapters are available out of the box, covering the most popular Angular UI libraries:

// Material Design
import { provideDynamicForm } from '@ng-forge/dynamic-forms';
import { withMaterialFields } from '@ng-forge/dynamic-forms-material';

export const appConfig = {
  providers: [provideDynamicForm(...withMaterialFields())],
};
Enter fullscreen mode Exit fullscreen mode

The same pattern works for Bootstrap (withBootstrapFields), PrimeNG (withPrimeNGFields), and Ionic (withIonicFields). Your form config stays the same - swap the adapter to change the UI.

Global Configuration

Each adapter accepts default options that apply to all fields:

provideDynamicForm(
  ...withMaterialFields({
    appearance: 'outline',
    subscriptSizing: 'dynamic',
  }),
);
Enter fullscreen mode Exit fullscreen mode

Custom Adapters

Don’t like the built-in adapters? Write your own. Each field type is an Angular component with a mapper function that transforms config into component inputs. Register custom fields with provideDynamicForm():

provideDynamicForm(
  { name: 'input', loadComponent: import('./my-custom-input-component'), mapper: myInputMapper },
  { name: 'select', loadComponent: import('my-custom-select-component'), mapper: mySelectMapper },
);
Enter fullscreen mode Exit fullscreen mode

You can also extend existing adapters — use the built-in fields as a starting point and override or add specific types.


Getting Started

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

Configure the provider in your application:

// app.config.ts
import { provideDynamicForm } from '@ng-forge/dynamic-forms';
import { withMaterialFields } from '@ng-forge/dynamic-forms-material';

export const appConfig: ApplicationConfig = {
  providers: [provideDynamicForm(...withMaterialFields())],
};
Enter fullscreen mode Exit fullscreen mode

Use the DynamicForm component in your template:

// my-form.component.ts
import { DynamicForm, FormConfig, InferFormValue } from '@ng-forge/dynamic-forms';

@Component({
  imports: [DynamicForm],
  template: `<form [dynamic-form]="config" [(value)]="formData" (submitted)="onSubmit($event)" />`,
})
export class MyFormComponent {
  config = {
    fields: [
      { key: 'name', type: 'input', label: 'Name', required: true },
      { key: 'email', type: 'input', label: 'Email', required: true, email: true },
      { key: 'submit', type: 'submit', label: 'Submit' },
    ],
  } as const satisfies FormConfig;
  formData = signal({ name: '', email: '' });
  onSubmit(value: InferFormValue<typeof this.config>) {
    console.log('Submitted:', value);
  }
}
Enter fullscreen mode Exit fullscreen mode

Full documentation: ng-forge.com/dynamic-forms


What’s Next

MCP Server (shipping soon OUT NOW): An MCP server that lets AI assistants generate form configurations with full schema awareness. Describe what you need, get valid TypeScript:

You: "Generate a 2-page registration with name, email, password on page 1 and address on page 2. Email and password required."

// MCP output (truncated):
const formConfig = {
  fields: [
    {
      key: 'page1',
      type: 'page',
      fields: [
        { key: 'name', type: 'input', label: 'Name' },
        { key: 'email', type: 'input', label: 'Email', required: true, email: true },
        { key: 'password', type: 'input', label: 'Password', required: true, props: { type: 'password' } },
        { key: 'next', type: 'next', label: 'Continue' },
      ],
    },
    {
      key: 'page2',
      type: 'page',
      fields: [
        { key: 'address', type: 'input', label: 'Address' },
        {
          key: 'navRow',
          type: 'row',
          fields: [
            { key: 'back', type: 'previous', label: 'Back', col: 6 },
            { key: 'submit', type: 'submit', label: 'Register', col: 6 },
          ],
        },
      ],
    },
  ],
} as const satisfies FormConfig;
Enter fullscreen mode Exit fullscreen mode

The server exposes tools for scaffolding, validation, and documentation lookup — the AI validates its output against the schema before returning code.

On the roadmap: An OpenAPI generator to create form configs from API specifications, and a visual form builder for non-technical users.

Give it a try — feedback and contributions welcome.

Documentation | GitHub | npm

Top comments (0)