Understanding the Foundation

The signal-based form we constructed earlier serves as our starting point. That form, however, lacked any meaningful business rules or validation mechanisms.

This discussion focuses on implementing those rules in a straightforward and maintainable manner.

Recap: The Signal-Based Form

To refresh, our initial form is structured as follows:

interface Assigned {
  name: string;
  firstname: string;
}

interface Todo {
  title: string;
  description: string;
  status: TodoStatus;
  assigned: Assigned[]
}


@Component({
  selector: 'app-form',
  templateUrl: './app-form.html',
  imports: [Control]
})
export class AppForm {
  todoModel = signal<Todo>({ 
    title: '',
    description: '', 
    status: 'not_begin',
    assigned: []
  }); // We create the model that will be the source of truth for the form and it's tree field

  todoForm = form(this.todoModel); // we create the form which is linked to the model
}
Enter fullscreen mode Exit fullscreen mode

As established, a FieldState instance exposes signals such as valid or disabled. These are not defined explicitly but rather derive from the field's underlying logic. In Angular's signals-based forms, they are computed via the computed primitive and are directly sourced from the form's field logic.

Defining Field Logic

A central idea in signals-based forms is that business logic and validation are defined declaratively within the TypeScript form definition. This brings two significant implications:

  • There are no imperative commands to change a field's state after the fact; methods like disable are simply absent.
  • Field requirements and disabled states are determined by other signals or static conditions defined at the form's creation.

At its core, the logic is expressed through a schema. A schema acts as a blueprint that encapsulates all the business rules for either the entire form or a specific Field.

To create a schema, you use the schema function. This generic function accepts two things:

  • The FieldPath interface as its generic type parameter.
  • A function that receives the FieldPath and specifies the rules.

The generic type is critical because it binds the type of the field to its validation rules. For instance, a schema typed for TODO describes rules for a field holding a TODO value, while a boolean schema outlines rules for boolean fields. This tight coupling guarantees that your logic is always in sync with the form's structure.

A schema can be created by calling schema() with a schema function or by passing the function directly to methods like form() that expect one. Once defined, the schema is linked to a Field structure by being the second argument in the form() call.

Let's illustrate this with code:

interface Assigned {
  name: string;
  firstname: string;
}

interface Todo {
  title: string;
  description: string;
  status: TodoStatus;
  assigned: Assigned[]
}

@Component({
  selector: 'app-form',
  templateUrl: './app-form.html',
  imports: [Control]
})
export class AppForm {
  todoModel = signal<Todo>({ 
    title: '',
    description: '', 
    status: 'not_begin',
    assigned: []
  }); // We create the model that will be the source of truth for the form and it's tree field

  todoForm = form(this.todoModel, (path: FieldPath<Todo>) => {}); // we create the form which is linked to the model
}
Enter fullscreen mode Exit fullscreen mode

The above method, which defines a schema via an inline function, has a limitation: it isn't scalable. The schema cannot be exported from a library or reused across your whole application.

To address this, we can refactor the code into a more reusable form:

interface Assigned {
  name: string;
  firstname: string;
}

interface Todo {
  title: string;
  description: string;
  status: TodoStatus;
  assigned: Assigned[]
}

/**
* Define a schema exportable:
* - let you to create some library that export your custom schema
* - could be used later in an other place of your application
*/
export const TodoSchema = schema<Todo>(path => {});

@Component({
  selector: 'app-form',
  templateUrl: './app-form.html',
  imports: [Control]
})
export class AppForm {
  todoModel = signal<Todo>({ 
    title: '',
    description: '', 
    status: 'not_begin',
    assigned: []
  });
  todoForm = form(this.todoModel, TodoSchema);
}
Enter fullscreen mode Exit fullscreen mode

Now that we've established a schema, it's time to give it some actual functionality.

Implementing Validation Logic

The signals-based form system provides built-in validators, much like Reactive Forms. Among the most common are:

For a comprehensive catalogue of all available validators, consult the official source.

Let's integrate these built-in validators into our form:

interface Assigned {
  name: string;
  firstname: string;
}

interface Todo {
  title: string;
  description: string;
  status: TodoStatus;
  assigned: Assigned[]
}

/**
* Define a schema exportable:
* - let you to create some library that export your custom schema
* - could be used later in an other place of your application
*/
export const TodoSchema = schema<Todo>(path => {
  required(path.title);
  required(path.description);
  minLength(path.description, 10);
});

@Component({
  selector: 'app-form',
  templateUrl: './app-form.html',
  imports: [Control]
})
export class AppForm {
  todoModel = signal<Todo>({ 
    title: '',
    description: '', 
    status: 'not_begin',
    assigned: []
  });
  todoForm = form(this.todoModel, TodoSchema);
}
Enter fullscreen mode Exit fullscreen mode

For situations where custom logic is required instead of the predefined validators, two additional functions are supplied: validate and error.

Harnessing the validate Function

The validate function enables the addition of custom validation rules to a field. It's possible to declare multiple rules for a single field, with all resulting errors accessible via the FieldState.

Here is an example of its usage:

const emailSchema = schema<string>(path => {
  validate(path, (value) => {
    const email = value();
    const sfeirPattern = /^\w+\.\w@sfeir.com$/;
    return sfeirPattern.test(email) ? 
      [] : [{ kind: 'sfeir-mail-incorrect', message: 'Required format: x.x@sfeir.com'}]
  });
})
Enter fullscreen mode Exit fullscreen mode

The power of validate extends far beyond simple checks like email format. Because it receives a FieldPath as its first argument, it can inspect and validate an entire form, not just a single field. This makes integrating with schema-based validation libraries like Zod extremely straightforward, allowing for a seamless merge between form and data validation.


const UserSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email'),
  age: z.number().min(18, 'Must be 18 or older'),
});

const userFormSchema = schema<User>(path => {
  validate(path, ({ value }) => {
    const validation = UserSchema.safeParse(value());
    return validation.success ?
      [] :
      validation.error!.issues.map(issue => ({
        kind: `${issue.path[0]_${issue.expected}`
        message: issue.message
      }))
  });

/**
* Focus of ({ value }) => {}
* This function is called an helper fonction
* Helper function take an object with three properties
* - value: signal that return the value of the `FieldPath`
* - valueOf: function that take a `FieldPath` and return the associate value
* - stateOf: function that take a `FieldPath` and return the `FieldState` of this `FieldPath`
* - fieldOf: function that take a `FieldPath` and return the `Field` of this `FieldPath` 
*/
});
Enter fullscreen mode Exit fullscreen mode

Simplifying with the error Function

The error function is a simpler variant of validate. Rather than returning an object of errors, it returns a boolean result, optionally paired with a user-friendly error message.

  • Returns true if validation passes.
  • Returns false if validation fails.
const emailSchema = schema<string>(path => {
  error(path, (value) => {
     const email = value();
     const sfeirPattern = /^\w+\.\w@sfeir.com$/;
     return sfeirPattern.test(email)
   }, 'Required format: x.x@sfeir.com');
})
Enter fullscreen mode Exit fullscreen mode

Built-in Composition

As demonstrated in the examples above, form signal validation offers considerable power, yet it can become verbose and quickly add complexity to our forms.

This is precisely where schema composition and flexibility prove their worth.

Let's revisit the earlier example.

export const TodoSchema = schema<Todo>(path => {
  required(path.title);
  required(path.description);
  minLength(path.description, 10);
  maxLength(path.description, 150);
});
Enter fullscreen mode Exit fullscreen mode

We are attaching three validations to the description field. With a small model, the impact is limited, but as the model expands, poor structuring and chaotic business logic management become a significant burden.

The composition pattern is the ideal approach here.

This pattern is straightforward, rooted in the concept of composing — combining smaller pieces to address a complex problem.

With Signals Form, composing through schemas is fully supported. This composition revolves around the straightforward apply function.

The apply function enables you to assign a schema to a FieldPath. Consequently, this function serves two purposes.

  • To decompose our form validation into smaller, more manageable schemas
  • To reuse generic schemas across our application, or even better, across multiple applications if we establish a schema library.
const descriptionSchema = schema<string>(path => {
  required(path);
  minLength(path.description, 10);
  maxLength(path.description, 150);
});

export const TodoSchema = schema<Todo>(path => {
  required(path.title);
  apply(path.description, descriptionSchema);
});
Enter fullscreen mode Exit fullscreen mode

The apply function has gained new allies.

  • applyEach facilitates applying a schema to every element within an array. This function accepts an Array-type FieldPath and a schema

  • applyWhen permits conditional schema application. This function takes the fieldPath on which to enforce validation, the condition that determines whether to apply it, and finally the schema.


interface User {
  email: string;
  password: string;
  confirmPassword: string;
}

const UserSchema = schema<User>(path => {
  required(path.email);
  applyWhen(
     path.confirmPassword,
     ({ valueOf }) => Boolean(valueOf(path).password),
     (confirmPasswordPath) => {
       required(confirmPassword)
     }
  )
});

@Component({
  selector: 'app-user',
  templateUrl: './user-form.html',
  imports: [Control]
})
export class AppForm {
  usersModel = signal<User[]>([]);
  usersForm = form(this.usersModel, (path => {
    applyEach(path);
  });
}
Enter fullscreen mode Exit fullscreen mode

Closing Thoughts

A form's business logic is articulated through a schema, which serves as a blueprint.

The signal form package offers a broad set of utilities, including pre-built validators and mechanisms that enable the composition pattern in forms.

What once demanded extensive code and careful deliberation on form architecture can now be handled with a handful of concise lines.

This represents a clear commitment to enhancing the developer experience, stripping away the intricacies of form construction so that developers can focus on their core business logic.