Form validation
There is exactly one validation engine here: Angular's Signal Forms. validationRules is declarative sugar that compiles into a schema — a required rule becomes required(), stringLength becomes minLength()/maxLength(), and so on. Nothing in @oge-ui/forms re-implements validation.
Which means the rules only apply where the form owns the model, i.e. [(formData)]. Bind [fieldTree] or [formGroup] and the caller owns validation; the form warns in dev mode if you set rules anyway.
Declarative rules
Rules are plain objects with a string-union type. Messages come from @oge-ui/inputs' message table, so the text under a field is identical whether the editor is used inside a form or on its own.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm, OgeFormItem } from '@oge-ui/forms';
@Component({
selector: 'demo-root',
imports: [OgeForm, OgeFormItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-form [(formData)]="signup" [showValidationSummary]="true">
<oge-form-item
field="username"
label="Username"
[validationRules]="[
{ type: 'required' },
{ type: 'stringLength', min: 3, max: 20 },
]"
/>
<oge-form-item
field="email"
label="E-mail"
[validationRules]="[{ type: 'required' }, { type: 'email' }]"
/>
<oge-form-item
field="age"
label="Age"
[validationRules]="[{ type: 'numeric', min: 18, max: 120 }]"
/>
</oge-form>
`,
})
export class Demo {
protected readonly signup = signal({ username: '', email: '', age: 0 });
}Custom & cross-field rules
A custom rule receives its own value and the whole model, which is how a confirm-password check works without a second engine. There is deliberately no compare rule type — a rule object that names another field loses type safety, and validate() with valueOf() in a real schema does it properly.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm, OgeFormItem } from '@oge-ui/forms';
import type { OgeValidationRule } from '@oge-ui/forms';
@Component({
selector: 'demo-root',
imports: [OgeForm, OgeFormItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-form [(formData)]="account">
<oge-form-item field="password" label="Password" [isRequired]="true" />
<oge-form-item field="confirm" label="Confirm" [validationRules]="matchRule" />
</oge-form>
`,
})
export class Demo {
protected readonly account = signal({ password: '', confirm: '' });
// a custom rule sees its own value and the whole model, so cross-field
// checks need no second engine
protected readonly matchRule: OgeValidationRule[] = [
{
type: 'custom',
validate: ({ value, data }) =>
value === data['password'] ? null : 'Passwords do not match',
},
];
}Angular Signal Forms
Bind the tree that form() returns. The schema owns everything: validity, required marks, disabled and read-only state. The form binds Angular's own FormField directive, so min/max/minLength/pattern reach the editors automatically — which is also why a template [disabled] binding is not written in this mode; the directive would overwrite it.
valid: false
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm, OgeFormItem } from '@oge-ui/forms';
import { form, required, email, minLength, disabled } from '@angular/forms/signals';
@Component({
selector: 'demo-root',
imports: [OgeForm, OgeFormItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- the schema owns validation, required marks, disabled and readonly -->
<oge-form [fieldTree]="profile" [colCount]="2">
<oge-form-item field="name" label="Name" />
<oge-form-item field="email" label="E-mail" />
<oge-form-item field="tenant" label="Tenant" />
</oge-form>
<p>valid: {{ profile().valid() }}</p>
`,
})
export class Demo {
protected readonly model = signal({ name: '', email: '', tenant: 'acme' });
protected readonly profile = form(this.model, (p) => {
required(p.name);
minLength(p.name, 2);
required(p.email);
email(p.email);
disabled(p.tenant, () => true);
});
}Reactive forms
An existing FormGroup binds straight in — the editors' control-value-accessor path carries the value, and the control's own validators drive the messages. Use this to put a form layout over code you are not ready to migrate.
group.valid: false
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeForm, OgeFormItem } from '@oge-ui/forms';
import { FormControl, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'demo-root',
imports: [OgeForm, OgeFormItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-form [formGroup]="group" [colCount]="2">
<oge-form-item field="name" label="Name" />
<oge-form-item field="email" label="E-mail" />
</oge-form>
`,
})
export class Demo {
protected readonly group = new FormGroup({
name: new FormControl('', Validators.required),
email: new FormControl('', [Validators.required, Validators.email]),
});
}Validation summary
The summary is a role="alert" list, so a failed submit is announced. Every row is a real button that focuses its field, and a failed submit also moves focus to the first invalid field and scrolls it into view. Render it yourself, or let the form do it with [showValidationSummary].
2 fields need your attention
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm, OgeFormItem, OgeValidationSummary } from '@oge-ui/forms';
import { OgeButton } from '@oge-ui/buttons';
@Component({
selector: 'demo-root',
imports: [OgeForm, OgeFormItem, OgeValidationSummary, OgeButton],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- place the summary yourself, or let the form render it with
[showValidationSummary]. Either way a row focuses its field. -->
<oge-validation-summary
[errors]="form.errors()"
(errorClick)="form.focus($event.field)"
/>
<oge-form #form [(formData)]="ticket" [scrollToFirstInvalid]="true">
<oge-form-item field="title" label="Title" [isRequired]="true" />
<oge-form-item field="reporter" label="Reporter" [isRequired]="true" />
<div ogeFormActions>
<oge-button text="Save" buttonType="submit" [useSubmitBehavior]="true" />
</div>
</oge-form>
`,
})
export class Demo {
protected readonly ticket = signal({ title: '', reporter: '' });
}Schema-carried layout
Angular 22's createMetadataKey() lets a schema carry more than validation. Attach a label, a hint, a group, a column span or an editor next to the rules, and <oge-form [fieldTree]> generates the entire layout — no items array, no template children. Angular's own REQUIRED/MIN/MAX/PATTERN metadata already reaches the editors, so none of it is duplicated.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm, OGE_FORM_LABEL, OGE_FORM_HINT, OGE_FORM_GROUP, OGE_FORM_COL_SPAN, OGE_FORM_EDITOR } from '@oge-ui/forms';
import { form, required, email, metadata } from '@angular/forms/signals';
@Component({
selector: 'demo-root',
imports: [OgeForm],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- no items, no children: the schema IS the layout -->
<oge-form [fieldTree]="profile" [colCount]="2" />
`,
})
export class Demo {
protected readonly model = signal({ name: '', email: '', bio: '' });
protected readonly profile = form(this.model, (p) => {
required(p.name);
required(p.email);
email(p.email);
metadata(p.name, OGE_FORM_LABEL, () => 'Full name');
metadata(p.email, OGE_FORM_LABEL, () => 'E-mail address');
metadata(p.email, OGE_FORM_HINT, () => 'Work address, please');
metadata(p.email, OGE_FORM_GROUP, () => 'Contact');
metadata(p.bio, OGE_FORM_EDITOR, () => 'textArea' as const);
metadata(p.bio, OGE_FORM_COL_SPAN, () => 2);
});
}Configuration
Every user-facing string — the required mark, the optional mark, the summary heading — lives in the messages interface, so a translation is one provider. Layout defaults can be set the same way and overridden per instance.
import { provideOgeFormsConfig } from '@oge-ui/forms';
export const appConfig: ApplicationConfig = {
providers: [
provideOgeFormsConfig({
labelLocation: 'start',
showOptionalMark: true,
messages: {
requiredMark: '•',
optionalMark: 'isteğe bağlı',
validationSummaryTitle: '{count} alan düzeltilmeli',
validationSummaryTitleOne: '1 alan düzeltilmeli',
},
}),
],
};