Forms with @craft-ng
@craft-ng provides a complete form management system, enabling the creation of reactive, type-safe, and composable forms with @craft-ng primitives. Unlike most form libraries, a form is derived from state, and all form logic and validators are also derived for a fully declarative form.
Overview
The main benefits of the @craft-ng form system are built on three pillars:
- Form Insertions - Modular composition to tackle logic complexity
- Type-safe errors - Synchronous and asynchronous validation with type-safe exceptions (inferred from validators and submit handler)
- Parallel Forms - Support for multiple forms in the same state with automatic scoping
All of this is possible because the logic is entirely derived from the state.
Form Insertions
Form insertions enable modular composition of functionality:
insertForm
The primary insertion that creates an Angular Signal-Form from a primitive.
import { state } from '@craft-ng/core';
import {
insertForm,
insertFormAttributes,
insertNoopTypingAnchor,
insertSelectFormTree,
cRequired,
cEmail,
} from '@craft-ng/core';
const userFormState = state(
{ name: '', email: '' },
insertForm(
insertSelectFormTree(
'name',
insertNoopTypingAnchor, // TS limitation
insertFormAttributes(() => ({
validators: [cRequired()],
})),
),
insertSelectFormTree(
'email',
insertNoopTypingAnchor, // TS limitation
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
})),
),
),
);
const form = userFormState.form();
const nameField = form.selectName();
const emailField = form.selectEmail();Note: It only works with the
stateprimitive from now.
insertNoopTypingAnchoris a special insertion that does not add any logic but allows to anchor the typing of the form field. It is required for the form system to infer the correct types of fields and exceptions. (TS limitations...)
insertFormAttributes
Adds attributes and validators to a form field.
const formState = state(
{ email: '' },
insertForm(
insertSelectFormTree(
'email',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
disable: () => isLoading(),
hidden: () => !showField(),
})),
),
),
);
// Access email field and its exceptions
const form = formState.form();
const emailField = form.selectEmail();
const errors = emailField()().exceptions.list; // fully typed list of exceptions
const emailError = emailField()().exceptions.byValidator['cEmail'];insertFormSubmit
Connects form submission to a mutation.
const updateUserMutation = mutation({
method: (data: ValidatedFormValue<UserForm>) => data,
loader: function* ({ params: user }) {
return yield* CraftHttpClient.patch(({ response }) => ({
url: '/api/users',
body: user,
success: response<User>(),
}));
},
});
const userFormState = state(
{ name: '', email: '' },
insertForm(
insertSelectFormTree(
'name',
insertNoopTypingAnchor,
insertFormAttributes(() => ({ validators: [cRequired()] })),
),
insertSelectFormTree(
'email',
insertNoopTypingAnchor,
insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })),
),
insertFormSubmit(updateUserMutation, {
success: () => {
console.log('Form submitted successfully');
return undefined;
},
}),
),
);
// Submit the form
userFormState.form().submit(); // Automatically triggers the mutationinsertSelectFormTree
Selects and composes nested sub-forms.
interface ProductForm {
name: string;
variants: Array<{
color: string;
stock: number;
}>;
}
const productFormState = state(
{ name: '', variants: [] } as ProductForm,
insertForm(
insertSelectFormTree(
'variant',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cMin({ min: 0 })],
})),
),
),
);
// Access sub-forms
const form = productFormState.form();
const variant0 = form.selectVariant(0);
const allVariants = form.items();insertSubFormField
Exposes a derived sub-form from a parent value through a lens. This is useful when the form field is not stored as a nested object in the state, but can still be read and written from the parent value.
import { state } from '@craft-ng/core';
import {
insertForm,
insertFormAttributes,
insertSubFormField,
splitLens,
cRequired,
} from '@craft-ng/core';
const appointmentFormState = state(
'2026-05-10 12:00',
insertForm(
insertSubFormField(
'date',
splitLens(' ', 0),
insertFormAttributes(() => ({
validators: [cRequired()],
})),
),
insertSubFormField('time', splitLens(' ', 1)),
),
);
const form = appointmentFormState.form();
const dateField = form.selectDate();
const timeField = form.selectTime();
console.log(dateField.value()); // '2026-05-10'
console.log(timeField.value()); // '12:00'
dateField.set('2026-05-11');
timeField.set('09:30');
console.log(appointmentFormState()); // '2026-05-11 09:30'Validators
@craft-ng provides a complete set of validators with structured exception handling:
Built-in Validators
cRequired
Checks that a value is present (not empty).
insertFormAttributes(() => ({
validators: [cRequired()],
}));
// With condition
insertFormAttributes(() => ({
validators: [cRequired({ when: () => fieldIsRequired() })],
}));cEmail
Checks that a string is a valid email.
insertFormAttributes(() => ({
validators: [cEmail()],
}));cMin / cMax
Checks that a numeric value is within a range.
insertFormAttributes(() => ({
validators: [cMin({ min: 18 }), cMax({ max: 100 })],
}));
// Dynamic values
insertFormAttributes(() => ({
validators: [cMin({ min: () => minimumValue() })],
}));cMinLength / cMaxLength
Checks the length of a string or collection.
insertFormAttributes(() => ({
validators: [cMinLength({ minLength: 8 }), cMaxLength({ maxLength: 500 })],
}));cPattern
Checks that a string matches a regex pattern.
insertFormAttributes(() => ({
validators: [cPattern({ pattern: /^\d{10}$/ })],
}));Custom Validators
cValidate
Creates a custom synchronous validator.
insertFormAttributes(() => ({
validators: [
cValidate({
name: 'passwordStrength',
validWhen: () => {
const pwd = password();
return pwd.length >= 8 && /[A-Z]/.test(pwd);
},
exception: () =>
craftException(
{ code: 'weak-password' },
{
message:
'Password must contain 8 characters and an uppercase letter',
},
),
}),
],
}));cAsyncValidate
Creates an asynchronous validator based on a resource (query or mutation).
It is not working yet, we are still working on it. The API is not final and may change.const checkEmailQuery = query({
params: () => ({ email: emailInput() }),
loader: async ({ params }) => {
const response = await fetch(`/api/check-email?email=${params.email}`);
return response.json();
},
});
insertFormAttributes(() => ({
validators: [
cAsyncValidate(checkEmailQuery, {
name: 'emailAvailability',
exceptionsOnSuccess: ({ validateAsyncCraftResource }) => {
if (!validateAsyncCraftResource.value()?.available) {
return craftException({ code: 'email-taken' }, undefined);
}
return undefined;
},
}),
],
}));Exception Handling
@craft-ng forms use a structured exception system:
const form = userFormState.form();
// All exceptions
const allErrors = form.exceptions().list;
// get first or last validation exception according to the order of validators
const first = fieldForm.form.firstLeftFailedValidation();
const last = fieldForm.form.lastRightFailedValidation();
// Exception by validator
const requiredError = form().selectEmail()().exceptions()?.byValidator[
'cRequired'
];
const emailError = form().selectEmail()().exceptions()?.byValidator.cEmail;
// Each exception has a code and payload
if (emailError) {
console.log(emailError.code); // 'email'
console.log(emailError.payload); // undefined
}
if (form.exceptions()?.byValidator?.cMin) {
const minError = form.exceptions()?.byValidator?.['cMin'];
console.log(minError.code); // 'min'
console.log(minError.payload); // 18 (the minimum value)
}Complete Examples
Creation Form with Validation
interface User {
name: string;
email: string;
age: number;
}
const createUserMutation = mutation({
method: (data: ValidatedFormValue<User>) => data,
loader: function* ({ params: user }) {
return yield* CraftHttpClient.update(({ response }) => ({
url: '/api/users',
body: user,
success: response<User>(),
}));
},
});
const userFormState = state(
{ name: '', email: '', age: 0 } satisfies User,
insertForm(
insertSelectFormTree(
'name',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired()],
})),
),
insertSelectFormTree(
'email',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
})),
),
insertSelectFormTree(
'age',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cMin({ min: 18 })],
})),
),
insertFormSubmit(createUserMutation),
),
);Complex Nested Form
interface Address {
street: string;
city: string;
zipCode: string;
}
interface User {
name: string;
email: string;
addresses: Address[];
}
const userFormState = state(
{
name: '',
email: '',
addresses: [],
} satisfies User,
insertForm(
insertSelectFormTree(
'name',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired()],
})),
),
insertSelectFormTree(
'email',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
})),
),
insertSelectFormTree(
'addresses',
insertNoopTypingAnchor,
insertSelectFormTree(
'street',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired()],
})),
),
insertSelectFormTree(
'city',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired()],
})),
),
insertSelectFormTree(
'zipCode',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cPattern({ pattern: /^\d{5}$/ })],
})),
),
),
insertSelectFormTree(
'address',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cMinLength({ minLength: 5 })],
})),
),
),
);