← Back to list

Exploring Cross-Field Validation on Angular Signal Forms

A seemingly simple validation rule turned into an unexpected lesson about abstraction, API design, and Angular Signal Forms.

Constantin Müller · 2026-06-25 21:44 · 30 claps · 14.0 min read
#angular #signal-forms #angular-signals #form-validation #cross-field-validation
Open on Medium ↗
Wiki topics: TLS · Design Tools & Workflow 🌐 · Web Development

Exploring Cross-Field Validation on Angular Signal Forms

Image created with assistance from an AI image-generation tool.

Image created with assistance from an AI image-generation tool.

A seemingly simple validation rule turned into an unexpected lesson about abstraction, API design, and Angular Signal Forms.

Disclosure: This story was developed and edited with assistance from an AI writing tool. The technical experience, code examples, and final editorial decisions are my own.

Over the past few Angular releases, Angular Signal Forms a schema-first approach to building forms.

A form is derived from a signal-backed data model, while schemas attach validation rules and field behaviour to the corresponding tree. This keeps form logic close to the structure it describes and makes individual rules easier to compose.

After the v21 release with Signal Forms as an experimental feature, they became stable in Angular v22. Although the API is intentionally small, it opens up interesting possibilities for building reusable validation logic that integrates directly with the form schema instead of being scattered throughout components.

If you haven’t explored Signal Forms yet, these are good starting points:

One of the aspects I find most compelling is that validation rules become first-class building blocks. Instead of embedding increasingly complex logic inside components, the schema itself can describe the business rules behind a form.

That was exactly the problem I thought I was solving.

Every developer has encountered validation rules that sound almost trivial.

One of my favourites is this:

At least one search criterion must be filled before the search can be submitted.

At first glance, there is nothing particularly interesting about it. A search form contains several optional fields, users may fill whichever ones they like, but submitting an entirely empty form should not be possible.

When I recently implemented exactly this requirement using Angular Signal Forms, I expected it to be straightforward.

It wasn’t.

One Form, Five Implementations

Before looking at any code, there is one important detail.

Throughout this article, the user interface never changes.

Every implementation validates exactly the same form. Every implementation produces exactly the same behaviour. If you were using the application, you would not notice which implementation was currently running.

The only thing that evolves is the implementation, and with it my understanding of the actual problem.

📸 Figure 1 — The Search Form

An demo person search form with contact details for natural and legal person. Every implementation in this article validates this exact form. The UI never changes.

An demo person search form with contact details for natural and legal person. Every implementation in this article validates this exact form. The UI never changes.

The form contains several independent search criteria. Users may search by phone number, email address, company information, personal information, or address. None of these fields is mandatory on its own, but at least one of them must contain a value before the search can be submitted.

From the user’s perspective, that is the entire story.

From the implementation’s perspective, it turned out to be surprisingly easy to solve-and surprisingly challenging to solve well.

The Obvious Solution

A first idea might be to use Angular Signal Forms’ conditional required() validator. One field becomes required when all other fields are empty.

required(path.contact.phone, {
  when: (ctx) =>
    !ctx.valueOf(path.contact.email) &&
    !ctx.valueOf(path.contact.address.street) &&
    !ctx.valueOf(path.contact.address.zip) &&
    // we assume that house "number"" is a string like '18a'"
    !ctx.valueOf(path.contact.address.number)
});

This looks simple.

Why This Is Not the Rule

The business rule does not say:

The phone field is required when all other fields are empty.

It says:

At least one search criterion is required.

Those aproaches can produce the same validation outcome, but they model the rule differently and attach it to different places in the form.

If the validation error should behave consistently, this conditional required() rule has to be mirrored for every participating field. Each field must become required when all other participating fields are empty.

Why This Approach Does Not Scale

Even before adding more business-specific rules, the naive approach already grows quickly:

Full search in natural and legal person data: 
12 fields × 11 checks = 132 checks

And that only counts value checks. It does not count duplicated error messages, repeated required() declarations, conditional branches, or the additional maintenance cost when the form changes.

The maths behind it means for n fields:

// a field must not be checked with itself
validators = n checks per validator = n - 1 

total checks = n × (n - 1)

That does not make the approach invalid. It can produce the intended validation behaviour.

But making that behaviour consistent comes at a cost. The same decision is now distributed across every participating field: each field needs its own conditional validator, and each validator needs to know about all the others.

As the form grows, the rule becomes harder to see. It is no longer expressed in one place. Instead, it is spread across the fields it is meant to coordinate.

What changed
✅ Validation logic solves the issue.
❌ The implementation is very verbose.
❌ The same form knowledge is repeated across multiple validators.
❌ The validation is tightly coupled to the current form structure.

Cross-Field Validation

At this point, I was not trying to optimise the algorithm. I was simply getting tired of making every conditional validator rediscover the same information.

Each validator needed access to the same list of participating fields and then had to answer the same follow-up question: has any other field already been filled?

The first part was easy to extract. Instead of rebuilding that list inside every required() condition, I created one shared function that determined which fields belonged to the current search.

getRelevantFields()

Instead of duplicating the logic, I extracted the first piece that was obviously shared: the list of fields participating in the validation.

The validator determines which fields actually participate.

const getRelevantFields = (ctx: RootFieldContext<unknown>) => {

    // Fields that always participate
    return [
        path.contact.phone,
        path.contact.email,
        path.contact.address.zip,
        path.contact.address.street,
        path.contact.address.number,

        // Add the fields that belong to natural person
        path.naturalPerson.firstname,
        path.naturalPerson.lastname,
        path.naturalPerson.birthYear,

        // Add the fields that belong to natural perso
        path.legalPerson.companyName,
        path.legalPerson.legalForm,
        path.legalPerson.employeeCount,
        path.legalPerson.revenue,
    ];
};

isAnyOtherFieldFilled()

Once the participating fields were known, checking whether another field had already been filled became surprisingly straightforward.

const isAnyOtherFieldFilled = (
    ctx: RootFieldContext<unknown>,
    currentPath: SchemaPathTree<unknown>,
) => {

    return getRelevantFields(ctx)

        // Ignore the current field. Otherwise every field
        // would satisfy its own validation.
        .filter(field => field !== currentPath)

        // Stop as soon as one meaningful value is found.
        .some(field => isFilled(ctx.valueOf(field)));
};

Usage Simplified

Once I had that list, the rest of the validation became surprisingly small. Instead of hardcoding long boolean expressions into every validator, I could simply ask whether any other participating field already contained a value.

required(path.contact.phone, {
    when: (ctx) =>
        !isAnyOtherFieldFilled(ctx, path.contact.phone),
});

required(path.naturalPerson.firstname, {
    when: (ctx) =>
        !isAnyOtherFieldFilled(ctx, path.naturalPerson.firstname),
});

The business rule still hadn’t changed. The implementation had become easier to extend, the duplicated logic had disappeared, and the amount of handwritten validation logic had dropped from quadratic to linear growth. Adding another search criterion no longer meant rewriting boolean expressions-it simply meant adding another field to a single collection. It felt like I was moving in the right direction. And yet, after staring at this implementation for a while, something still bothered me.

The validation knew exactly which fields belonged to natural persons, which belonged to legal entities, and which were shared between both. It had become an expert on my application’s structure. But that wasn’t really its job.

What changed
✅ Handwritten validation logic was no longer duplicated.
✅ Quadratic LOC growth became linear.
❌ The runtime evaluation cost stays quadratic.
❌ The validator was still tightly coupled to the application's structure.
❌ It still thought in fields instead of forms.

A Validator Should Not Know My Form

The previous implementation was heading in the right direction. The duplicated conditions had disappeared, and the handwritten validation logic had become much easier to maintain.

Still, I wasn’t happy with where that code lived.

The entire validation was implemented inside my component. As the search form continued to evolve, the component gradually became responsible for more than just rendering the UI. It described the form, reacted to user interaction, and contained a surprisingly sophisticated validation algorithm.

Trying to Separate Concerns

So I did what I usually do when a component starts taking on too many responsibilities: I tried to move the validation into its own helper. Not because I had plans to build a reusable library, but simply because I wanted the component to focus on describing the UI while the validator focused on describing the validation.

That refactoring immediately exposed a problem I hadn’t noticed before. The validator wasn’t independent-it couldn’t be. It knew everything about the search form. That knowledge was deeply embedded in the implementation.

Seen from outside the component, that suddenly looked very strange. I hadn’t extracted a validator at all. I had simply moved part of my component into another file.

A Different Abstraction

Eventually, I realised that I had been giving the validator the wrong input. It did not need a list of fields to start with; it needed the part of the form the rule applied to.

Angular Signal Forms provides [validateTree()](https://angular.dev/api/forms/signals/validateTree) for registering a custom validation rule on that subtree ¹. The rule was no longer attached to a single field as a workaround-it belonged to the form section it described.

This did not remove the explicit list yet. It only changed where the rule lived. Determining which fields participated was the next problem.

validateTree(path, (ctx) => {

    const selectedPaths = [
        path.contact.phone,
        path.contact.email,
        path.contact.address.zip,
        path.contact.address.street,
        path.contact.address.number,
        path.naturalPerson.firstname
        //...
    ];

    const anyFilled = selectedPaths.some((path) =>
        isFilled(ctx.valueOf(path))
    );

    if (anyFilled) {
        return null;
    }

    return {
        kind: 'atLeastOne',
        // we choose the first field to show the error
        fieldTree: ctx.fieldTreeOf(path.contact.phone),
    };
});

The actual validation logic also became much easier to reason about. Instead of embedding long boolean expressions inside every validator, the implementation only needs to answer one simple question:

Does this subtree contain any meaningful user input?

const isFilled = (value: unknown) =>
    value != null && value !== '';

The isFilled() function also solves a so far hidden problem: Validators like required treat values like false, null, undefined, '' and NaN, as missing ( 0 is valid though).

For a cross-field rule, defining isFilled() explicitly lets the form decide which values count as meaningful input. A number input may legitimately contain 0, and a radio option may deliberately store false.

The real breakthrough was the abstraction behind it.

The validator no longer behaves like a specialised field validator that happens to inspect other fields. Instead, it expresses a business rule that belongs to an entire subtree of the form.

That feels much closer to the original requirement-and, perhaps more importantly, much closer to something that could eventually become truly reusable.

One Step Closer

Moving the validation to the form tree solved an important design problem. The validation logic was finally separated from the UI, making both the component and the validator easier to understand.

Although the validator no longer lived inside the component, it still depended on an explicit list of participating fields. It was becoming a much better abstraction-but it wasn’t reusable yet.

What changed
✅ The validation logic is now separated from the component.
✅ The business rule is attached to a form subtree instead of individual fields.
✅ The implementation models the problem more directly.
❌ The validator still requires an explicit list of participating fields.
❌ The form structure is still part of the validator's knowledge.

From Implementation to API

At this point, the validation had finally become a separate concern.

The component described the form, while the validator described the business rule. That alone made both pieces of code significantly easier to understand. For the first time, I could imagine turning it into a reusable utility instead of relocating component knowledge.

As I continued working on other forms, I noticed something interesting. The validation logic itself never really changed. Whether the form searched for customers, contacts, or something entirely different, the algorithm always remained the same. Only one thing varied from form to form: which fields should participate in the validation.

That observation completely changed the way I approached the problem. Up until now, I had been writing implementations. But if the implementation was always the same, perhaps I shouldn’t be thinking about the implementation at all. Perhaps I should start by designing the API.

Designing the API First

Whenever I build something that I expect to reuse, I try to answer one question before writing any code:

What would I like this to look like?

For this validator, the answer felt surprisingly obvious.

atLeastOne(
    path,
    [
        p => p.contact.phone,
        p => p.contact.email,
        p => p.contact.address,
    ],
    p => p.contact.phone,
);

What I like about this API is how little it reveals about its implementation. There are no loops, no tree traversal, and no conditional logic. Instead, the schema simply expresses its intent:

At least one of these fields must contain a value.

Reading this code tells me what the form requires, not how that requirement is enforced. That was the distinction I had been looking for.

Turning the API into Code

With that distinction in place, the next implementation step became much clearer.

The validator resolves the selected field paths, performs the validation on the form tree, and attaches the error to the configured target field. All the complexity that previously lived inside every component now exists in exactly one place.

export function atLeastOne<T>(
    // Root path used as the context for every selector. 
    path: SchemaPath<T>,
    // Selects the paths that participate in the rule.
    selectors: readonly Selector<T>[]
): void {

    validateTree(path, (ctx) => {

        const selectedPaths = selectors.map(selector =>
            selector(path as SchemaPathTree<T>)
        );

        const anyFilled = selectedPaths.some(path =>
            isFilled(ctx.valueOf(path))
        );

        return anyFilled
            ? null
            : {
                  kind: 'atLeastOne',
                  fieldTree: ctx.fieldTreeOf(
                  // the first Selector receives the validation error.
                      selectors[0](path as SchemaPathTree<T>)
                  ),
              };
    });
}

// a selector is a derived sub-field of a root of specified type T
// but we wont know anything about the sub-fields type.
type Selector<T extends object> = 
   (p: SchemaPathTree<T>) => SchemaPathTree<unknown>;

Looking back, I find it interesting that the implementation itself isn’t particularly remarkable. Most of the challenging work had already happened while searching for the right abstraction. Once the API clearly expressed the business rule, the code became little more than a translation of that idea into Angular Signal Forms.

A Reusable Validator

This was the first version that genuinely felt reusable.

The validator no longer knew anything about phone numbers or company names. It didn’t even know that it was validating a search form. It only knew three things: where the validation belongs, which fields participate, and where the validation error should be attached.

For the first time, I felt like I wasn’t writing application code any more. I was designing an API. That feeling didn’t last forever, though. As soon as the validator reached production, another assumption I’d been making quietly revealed itself.

Let the Validator Decide

By this point, the validator had finally become something I enjoyed using.

validateTree() had moved the rule away from individual field validators and onto the part of the form tree it actually belonged to. The validation logic lived outside the component, the API was concise, and the implementation could be reused across different search forms.

But the caller still had to provide one thing manually: the complete list of participating leaf fields. It felt like the right level of abstraction-at least for a while.

The List Became a Growing Burden

As the search forms became larger, another pattern slowly emerged.

The validation logic itself never changed. The validator still answered exactly the same business rule:

At least one search criterion must be filled.

What kept changing was the list of participating fields.

Every new search criterion meant adding another selector. Every new reusable form section contributed another handful of fields. The API itself remained simple, but the list gradually became the largest part of every validator call.

atLeastOne(
    path,
    [
        p => p.contact.phone,
        p => p.contact.email,
        p => p.contact.address.street,
        p => p.contact.address.number,
        p => p.contact.address.zip,

        p => p.naturalPerson.firstname,
        p => p.naturalPerson.lastname,
        p => p.naturalPerson.birthYear,

        p => p.legalPerson.companyName,
        p => p.legalPerson.legalForm,
        p => p.legalPerson.employeeCount,
        p => p.legalPerson.revenue,

        // ...
    ],
    p => p.contact.phone,
);

For small forms, I still think it’s a reasonable solution.

My search forms, however, were no longer small. They contained reusable components, nested object structures, and dozens of searchable fields. Maintaining these lists quickly became tedious, and every time the form evolved, I found myself answering exactly the same question once again.

It also required the caller to get every selector exactly right. Selecting an intermediate node instead of a leaf field didn’t express the business rule any differently, but it changed the behaviour of the validator.

That’s a good sign that the API is asking too much of its users.

The List Disappears

Instead of asking the developer to provide an explicit list of participating fields, I started experimenting with a different idea.

What if the validator determined the participating fields itself?

Instead of configuring which fields should be validated, I could simply tell the validator where to start looking. The API immediately became much smaller.

atLeastOne(path);

At first glance, that almost looks too simple. The interesting part, however, isn’t the API itself. It’s how the validator interprets that single path.

From Subtrees to Fields

The validator now treats the supplied path as the root of the area it is responsible for. From there, it traverses the form tree and expands nested objects into the actual leaf fields that can participate in the validation.

p => p.email // one field 
p => p.contact // one subtree 
path // the whole form

Internally, the implementation remains small. validateTree() still registers the rule on the correct part of the form tree, just as it did in the previous iteration. The new part is the traversal step that derives the participating fields from that tree.

export function atLeastOne<T>(
    path: SchemaPath<T>,
): void {
    validateTree(path, (ctx) => {
        const pathTree = path as SchemaPathTree<T>;

        // Resolve the selected roots.
        const selectedRoot = pathTree as SchemaPathTree<unknown>;

        // Expand the root into its participating leaf fields.
        const selectedPaths = collectLeafPaths(selectedRoot, ctx);

        // Validation succeeds when one participating field is filled.
        const anyFilled = selectedPaths.some((selectedPath) =>
            isFilled(ctx.valueOf(selectedPath)),
        );

        // ...
    });
}

The caller now defines the scope of the rule rather than enumerating its implementation details. The validator receives a subtree, derives the relevant leaf fields from it, and then applies the same question as before.

Which fields participate in the validation?

The difference is simply that the application developer no longer has to answer it.

The validator does.

Collecting the leaves is the key. The function needs to be recursive so that it can traverse nested objects until it reaches every leaf path in the model structure.

It looks like:

function collectLeafPaths(
    path: SchemaPathTree<unknown>,
    ctx: Parameters<Parameters<typeof validateTree>[1]>[0],
): SchemaPathTree<unknown>[] {
    const value = ctx.valueOf(path);

    // Primitive values, arrays and Date objects are leaf fields.
    if (!isTraversableObject(value)) {
        return [path];
    }

    const pathTree = path as Record<string, SchemaPathTree<unknown>>;

    // Objects are expanded recursively into their leaf fields.
    return Object.keys(value).flatMap((key) =>
        collectLeafPaths(pathTree[key], ctx),
    );
}

function isTraversableObject(
    value: unknown,
): value is Record<string, unknown> {
    return (
        typeof value === 'object' &&
        value !== null &&
        !Array.isArray(value) &&
        !(value instanceof Date)
    );
}

This answers the structural question: „which leaf fields exist below this root?”

There was a connection to the earlier refactoring that only became obvious at this point. collectLeafPaths() was really solving the same problem as getRelevantFields(): it determined which fields should participate in the validation.

The difference was where that knowledge came from.

getRelevantFields() had to describe the application explicitly. It shared fields and the individual branches of this particular search form. collectLeafPaths(), on the other hand, only knows how to walk a form tree. It starts at a given root and derives the fields below it from the structure that already exists.

In that sense, the traversal was not an entirely new idea. It was the structural, reusable version of the helper I had written much earlier.

What changed
✅ The caller now defines the scope of the rule
     - instead of enumerating every leaf field.

✅ Nested model structures can be handled 
     - without teaching the validator.

✅ Field discovery is derived from the structure 
     - already represented by the schema path.

Where It Ended Up

The implementation shown in this article isn’t the final version.

It continued to evolve through real-world projects and eventually became the requiredAtLeastOne() validator that is now part of @devzwo/ngx-signal-schema. Along the way it gained support for recursive traversal, configurable error placement, and several other improvements that grew out of practical use rather than upfront design.

If you’re curious about the current implementation or want to use it in your own projects, you can find it here:

Where the Journey Continued

Reaching a reusable API and publishing the validator did not close the story.

Once it was used in real forms, the automatic traversal raised new questions.

Should disabled fields participate? What about hidden fields? How should structural fields be excluded? What exactly counts as a „filled" value? What about structural or metadata fields like the person type that may be technically part of the tree but are not search criteria? And where should a cross-field error appear in the UI?

Those questions led to further iterations of requiredAtLeastOne(). They changed the traversal rules, error handling and configuration options, but not the central idea: the validator receives a part of the form tree and determines which active fields belong to the rule.

I started with a small validation requirement.

The interesting part was discovering the abstraction.

Everything else followed almost naturally.

Originally published at https://devzwo.com.


메타데이터
post_id
0e5736376ce5
slug
exploring-cross-field-validation-on-angular-signal-forms-0e5736376ce5
url
https://medium.com/@mue.con/exploring-cross-field-validation-on-angular-signal-forms-0e5736376ce5
canonical_url
https://medium.com/@mue.con/exploring-cross-field-validation-on-angular-signal-forms-0e5736376ce5
author_url
https://medium.com/@mue.con
status
ok
fetched_at
2026-07-09 05:26:43