Skip to main content

Switch / Case — Advanced Conditional Validation

This document describes two complementary features for conditional validation in Vali-Validation: RuleSwitch and SwitchOn. Both allow applying different sets of rules depending on a runtime value, but they operate at different levels of granularity and serve distinct purposes.


Overview

FeatureLevelKey question
RuleSwitchValidator (multiple properties)"Which group of rules should I run for the whole object?"
SwitchOnProperty (single property)"Which rules apply to this specific field depending on another field?"

Both features share the same core semantics:

  • Exclusivity: only one case executes per validation run. As soon as a matching key is found, the remaining cases are skipped.
  • Default fallback: an optional .Default(...) branch runs when no case matches.
  • No match, no Default: if no case matches and no Default is defined, the switch block produces no errors (it is silent, not an error in itself).
  • Global rules are unaffected: rules defined outside the switch always execute.

RuleSwitch vs SwitchOn Decision Tree

loading...

Comparison at a Glance

RuleSwitchSwitchOn
Where it is calledInside AbstractValidator<T> constructorChained on a RuleFor(...) call
DiscriminatorAny property of the root objectAny property of the root object
Scope of each caseMultiple properties, full rule setsA single property
Case body typeAction<AbstractValidator<T>>Action<IRuleBuilder<T, TProperty>>
Global rules mixed inYes (defined outside the switch)Yes (defined before .SwitchOn(...))
Async rule supportYes (MustAsync, DependentRuleAsync)Yes (MustAsync, WhenAsync)

Part 1: RuleSwitch

What Is RuleSwitch

RuleSwitch is a validator-level switch/case block. It is defined inside the constructor of a class that inherits AbstractValidator<T>. It reads a discriminator value from the object being validated and applies the matching case's rules — rules that can span multiple properties.

The method signature is:

protected ICaseBuilder<T, TKey> RuleSwitch<TKey>(Expression<Func<T, TKey>> keyExpression)

ICaseBuilder Interface

public interface ICaseBuilder<T, TKey> where T : class
{
// Applies rules when the discriminator equals value
ICaseBuilder<T, TKey> Case(TKey value, Action<AbstractValidator<T>> configure);

// Applies rules when no case matches
ICaseBuilder<T, TKey> Default(Action<AbstractValidator<T>> configure);
}

Example 1 — Payment Method (String Discriminator)

public class PaymentValidator : AbstractValidator<PaymentDto>
{
public PaymentValidator()
{
// Global rules — always execute regardless of Method
RuleFor(x => x.Amount)
.GreaterThan(0)
.WithMessage("The Amount field must be greater than zero.");

RuleFor(x => x.Method)
.NotEmpty()
.WithMessage("The Method field is required.");

// Switch: exactly one case executes based on x.Method
RuleSwitch(x => x.Method)
.Case("credit_card", rules =>
{
rules.RuleFor(x => x.CardNumber)
.NotEmpty()
.WithMessage("The CardNumber field is required for credit card payments.")
.CreditCard()
.WithMessage("The CardNumber field must be a valid credit card number.");

rules.RuleFor(x => x.Cvv)
.NotEmpty()
.WithMessage("The Cvv field is required.")
.MinimumLength(3)
.MaximumLength(4)
.WithMessage("The Cvv field must be between 3 and 4 characters.");

rules.RuleFor(x => x.ExpirationDate)
.NotNull()
.WithMessage("The ExpirationDate field is required.")
.FutureDate()
.WithMessage("The ExpirationDate must be in the future.");
})
.Case("bank_transfer", rules =>
{
rules.RuleFor(x => x.Iban)
.NotEmpty()
.WithMessage("The Iban field is required for bank transfer payments.")
.Iban()
.WithMessage("The Iban field must be a valid IBAN.");

rules.RuleFor(x => x.BankCode)
.NotEmpty()
.WithMessage("The BankCode field is required.");
})
.Case("paypal", rules =>
{
rules.RuleFor(x => x.PaypalEmail)
.NotEmpty()
.WithMessage("The PaypalEmail field is required for PayPal payments.")
.Email()
.WithMessage("The PaypalEmail field must be a valid email address.");
})
.Default(rules =>
{
// Executed when Method is anything other than the three cases above
rules.RuleFor(x => x.Reference)
.NotEmpty()
.WithMessage("The Reference field is required for this payment method.");
});
}
}

Usage

var validator = new PaymentValidator();

// Scenario: credit card with missing CVV
var payment = new PaymentDto
{
Amount = 150.00m,
Method = "credit_card",
CardNumber = "4111111111111111",
Cvv = null, // missing
ExpirationDate = DateTime.Now.AddYears(2)
};

var result = await validator.ValidateAsync(payment);
// result.IsValid == false
// result.Errors["Cvv"] == ["The Cvv field is required."]
// "Iban", "BankCode", "PaypalEmail" are NOT reported — those cases were skipped

Example 2 — User Type (Enum Discriminator)

public enum UserType { Admin, Client, Guest }

public class CreateUserValidator : AbstractValidator<CreateUserDto>
{
public CreateUserValidator()
{
// Global — applies to every user type
RuleFor(x => x.Username)
.NotEmpty()
.MinimumLength(3)
.MaximumLength(50)
.IsAlphanumeric();

RuleFor(x => x.Email)
.NotEmpty()
.Email();

RuleSwitch(x => x.Type)
.Case(UserType.Admin, rules =>
{
rules.RuleFor(x => x.AdminAccessCode)
.NotEmpty()
.WithMessage("Admins must provide an access code.")
.MinimumLength(16)
.WithMessage("The admin access code must be at least 16 characters.");

rules.RuleFor(x => x.Permissions)
.NotEmptyCollection()
.WithMessage("Admins must have at least one permission assigned.");
})
.Case(UserType.Client, rules =>
{
rules.RuleFor(x => x.CompanyName)
.NotEmpty()
.WithMessage("Company name is required for client accounts.");

rules.RuleFor(x => x.TaxId)
.NotEmpty()
.WithMessage("Tax ID is required for client accounts.")
.Matches(@"^[A-Z0-9]{9,13}$")
.WithMessage("Tax ID must be between 9 and 13 uppercase alphanumeric characters.");
})
.Case(UserType.Guest, rules =>
{
rules.RuleFor(x => x.SessionToken)
.NotEmpty()
.WithMessage("A session token is required for guest access.")
.Guid()
.WithMessage("The session token must be a valid GUID.");
});
// No .Default() — if Type is an unknown enum value, only global rules run
}
}

Part 2: SwitchOn

What Is SwitchOn

SwitchOn is a property-level switch. It is chained directly on a RuleFor(...) call and determines which rules to apply to that single property based on the value of another property.

The method signature is:

public ISwitchOnBuilder<T, TProperty, TKey> SwitchOn<TKey>(
Expression<Func<T, TKey>> keyExpression)

ISwitchOnBuilder Interface

public interface ISwitchOnBuilder<T, TProperty, TKey> where T : class
{
ISwitchOnBuilder<T, TProperty, TKey> Case(
TKey value,
Action<IRuleBuilder<T, TProperty>> configure);

ISwitchOnBuilder<T, TProperty, TKey> Default(
Action<IRuleBuilder<T, TProperty>> configure);
}

Example: Document Number Validation

public class DocumentValidator : AbstractValidator<DocumentDto>
{
public DocumentValidator()
{
RuleFor(x => x.DocumentNumber)
.SwitchOn(x => x.DocumentType)
.Case("passport", b => b
.NotEmpty()
.Matches(@"^[A-Z]{2}\d{6}$")
.WithMessage("The passport number must have the format AA999999."))
.Case("dni", b => b
.NotEmpty()
.IsNumeric()
.MinimumLength(8)
.MaximumLength(8)
.WithMessage("The DNI must be exactly 8 digits."))
.Case("ruc", b => b
.NotEmpty()
.IsNumeric()
.MinimumLength(11)
.MaximumLength(11)
.WithMessage("The RUC must be exactly 11 digits."))
.Default(b => b.NotEmpty());
}
}

SwitchOn vs When/Unless

SwitchOnWhen / Unless
ExclusivityOnly one case runsEach When block is evaluated independently
Multiple variantsOne construct covers all variantsRequires one RuleFor(...).When(...) per variant
OverlapImpossible by designPossible if conditions are not mutually exclusive

Combining RuleSwitch and Global Rules

Rules declared before RuleSwitch always execute regardless of which case matches. This lets you express a clean separation: global invariants at the top, variant-specific invariants inside the switch.

public class PaymentValidator : AbstractValidator<PaymentDto>
{
public PaymentValidator()
{
// Global rules: always evaluated
RuleFor(x => x.Amount).Positive();
RuleFor(x => x.Currency).NotEmpty().CurrencyCode();

// Variant rules: only the matching case is evaluated
RuleSwitch(x => x.Method)
.Case("credit_card", rules =>
{
rules.RuleFor(x => x.CardNumber).NotEmpty().CreditCard();
rules.RuleFor(x => x.Cvv).NotEmpty().MinimumLength(3).MaximumLength(4);
rules.RuleFor(x => x.CardHolder).NotEmpty();
})
.Case("bank_transfer", rules =>
{
rules.RuleFor(x => x.Iban).NotEmpty().Iban();
rules.RuleFor(x => x.BankName).NotEmpty();
})
.Case("paypal", rules =>
{
rules.RuleFor(x => x.PaypalEmail).NotEmpty().Email();
});
}
}

The Amount and Currency rules run first for every payment, then exactly one case from the switch runs based on Method. If Method does not match any case and no .Default(...) is provided, the switch contributes no additional errors.


Notification Entity with Channel-Dependent Fields

public class NotificationValidator : AbstractValidator<NotificationRequest>
{
public NotificationValidator()
{
// Common rules — always validated
RuleFor(x => x.Title).NotEmpty().MaximumLength(100);
RuleFor(x => x.Body).NotEmpty().MaximumLength(500);

RuleFor(x => x.Channel)
.NotEmpty()
.In(new[] { "email", "sms", "push" })
.WithMessage("The notification channel must be email, sms or push.")
.StopOnFirstFailure();

// Channel-specific rules
RuleSwitch(x => x.Channel)
.Case("email", rules =>
{
rules.RuleFor(x => x.RecipientEmail)
.NotEmpty()
.WithMessage("An email address is required for email notifications.")
.Email()
.WithMessage("The recipient email address is not valid.");
})
.Case("sms", rules =>
{
rules.RuleFor(x => x.RecipientPhone)
.NotEmpty()
.WithMessage("A phone number is required for SMS notifications.")
.PhoneNumber()
.WithMessage("The recipient phone number is not valid.");

// SMS bodies are limited by character count
rules.RuleFor(x => x.Body)
.MaximumLength(160)
.WithMessage("SMS messages cannot exceed 160 characters.");
})
.Case("push", rules =>
{
rules.RuleFor(x => x.DeviceToken)
.NotEmpty()
.WithMessage("A device token is required for push notifications.");

rules.RuleFor(x => x.PushPlatform)
.NotEmpty()
.WithMessage("The push platform must be specified.")
.In(new[] { "ios", "android" })
.WithMessage("The push platform must be ios or android.");
});
}
}

Next Steps