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
}
}
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.
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;
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 }
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;
type Value = InferFormValue<typeof form>;
// Result: { name: string; address: { street: string; city: string } }
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
}
}
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
}
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]).*$' }
]
}
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'
}
}
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;
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;
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',
},
},
],
}
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',
},
},
];
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.
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',
},
],
}
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',
},
],
}
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' }],
}
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' }
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;
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())],
};
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',
}),
);
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 },
);
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())],
};
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);
}
}
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;
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)