Skip to content

Company vs Private Person Contact Form

This recipe demonstrates how the core concepts of ngx-signal-schema work together in a realistic form.

The form supports two customer types:

  • Private Person
  • Company

Depending on the selected type, different parts of the form become active.

Along the way we will use:

  • Schema Composition
  • Conditional Schemas
  • Reusable Validators
  • Stable Form Structures

Every contact provides:

  • Customer Type
  • Email
  • Country

Private persons additionally provide:

  • First Name
  • Last Name

Companies additionally provide:

  • Company Name
  • Legal Form
  • VAT Number

Only the fields relevant for the selected customer type should be visible, enabled and validated.

Instead of placing all fields directly on the root object, we model the two customer types as separate subtrees.

interface ContactForm {
customerType: 'private' | 'company';
email: string;
country: string;
privatePerson: {
firstName: string;
lastName: string;
};
company: {
companyName: string;
legalForm: string;
vatNumber: string;
};
}

This creates two stable form branches:

ContactForm
├── customerType
├── email
├── country
├── privatePerson
│ ├── firstName
│ └── lastName
└── company
├── companyName
├── legalForm
└── vatNumber

The form structure never changes.

Only the active branch changes.

This is much easier to reason about than constantly creating and removing fields.

Each subtree owns its own validation rules.

const PrivatePersonSchema = schema<ContactForm['privatePerson']>((path) => {
requiredTrimmed(path.firstName);
requiredTrimmed(path.lastName);
});
const CompanySchema = schema<ContactForm['company']>((path) => {
requiredTrimmed(path.companyName);
requiredDefined(path.legalForm);
});

The schemas are independent and reusable.

The selected customer type determines which subtree is active.

Private person:

applyIf(
path.privatePerson,
valueEquals(path.customerType, 'private'),
PrivatePersonSchema,
// This is the short version of:
// (path) => disabledHidden(path)
disabledHidden
);

Company:

applyIf(
path.company,
valueEquals(path.customerType, 'company'),
CompanySchema,
// This is the short version of:
// (path) => disabledHidden(path)
disabledHidden
);

This is where applyIf() becomes particularly useful.

Angular Signal Forms already provide applyWhen():

applyWhen(
path.company,
valueEquals(path.customerType, 'company'),
CompanySchema
);

However, that only describes the active state.

In many forms the inactive state is equally important.

When a user selects “Private Person”, the company section should not only stop validating. It should also become hidden and disabled.

With applyWhen() this usually requires a second negated condition.

applyIf() keeps both states together in one place.

applyIf(
path.company,
valueEquals(path.customerType, 'company'),
CompanySchema,
(path) => disabledHidden(path)
);

The complete behavior can be understood from a single statement.

VAT numbers are only relevant for DACH countries.

disabledHidden(
path.company.vatNumber,
not(
valueIn(path.country, [
'DE',
'AT',
'CH',
])
)
);

This rule is independent from the customer type logic.

Conditions remain small and composable.

The final schema is assembled from smaller building blocks.

export const ContactSchema = compose(
BaseContactSchema,
(path) => {
applyIf(
path.privatePerson,
valueEquals(path.customerType, 'private'),
PrivatePersonSchema,
(path) => disabledHidden(path)
);
applyIf(
path.company,
valueEquals(path.customerType, 'company'),
CompanySchema,
(path) => disabledHidden(path)
);
disabledHidden(
path.company.vatNumber,
not(
valueIn(path.country, [
'DE',
'AT',
'CH',
])
)
);
}
);

The final schema remains relatively small because each concern is isolated.

Imagine new requirements appear:

  • Enterprise customers
  • Readonly review mode
  • Country-specific tax rules
  • White-label variants

Instead of modifying one large schema, additional schema fragments can be added.

append(
ContactSchema,
EnterpriseRules,
ReviewModeRules,
CountrySpecificRules
);

Each feature contributes its own behavior without modifying unrelated parts of the form.

privatePerson: PrivatePersonForm;
company: CompanyForm;

Separate form branches create a stable and predictable form tree.

requiredTrimmed(...)
requiredDefined(...)

Validation logic is encapsulated into reusable building blocks.

applyIf(...)
valueEquals(...)
valueIn(...)

Different schema fragments become active depending on form state.

append(...)

The final schema is assembled from smaller pieces.

This example demonstrates the intended workflow of ngx-signal-schema.

  1. Model stable form structures.
  2. Encapsulate validation logic into reusable validators.
  3. Use conditions to activate and deactivate schema branches.
  4. Compose everything into the final schema.

Each concept has a single responsibility.

Together they allow large forms to remain understandable, modular and maintainable over time.