Skip to content

Writing a validator

The readers clean one value at a time and answer with the value, or with the honest nothing. A form, an import or an API endpoint wants something else first: every problem with the whole payload, at once, in words a person can act on. That is what Explain is for, and this page builds a validator out of it — a method that takes the bag a payload arrived in and hands back each field's reason.

You need Your first payload behind you: the bag, the readers, and why nothing throws.

The shape of a validator

Three moves, in order:

  1. What must be there at all. CkDictionary.MissingKeys answers the required keys that are absent — present-but-blank does not count as missing, the next move judges that.
  2. Each value, judged by the class that owns it. Every class with a reader has an Explain: CkEmail.Explain(value), CkMoney.Explain(value), and where a class has several readers, CkUS.Explain(value, nameof(CkUS.Zip)). It answers "" when the value passes and the reader's own reason otherwise — the same words throwIfInvalid: true would put in the exception — and it never throws, not even with the throw switch on.
  3. Rules between fields. The validators do those: CkDate.IsBefore for a start and an end date (CkDateTime.IsBefore when the two carry a time), CkDate.IsAdult for a birth date, CkIntl.PostalCode with the country beside it.

The result is a dictionary, field name to reason, empty when the payload is good.

The payload

The signup we will judge, as it came off the wire:

using SafeCoder.CommonKit;

string body = """
{
  "name": "   ",
  "email": "mary.smith.gmail.com",
  "country": "CA",
  "postalCode": "1234",
  "birthDate": "5/6/26",
  "amount": "ten"
}
""";

Dictionary<string, object?> payload = CkPayload.FromJson(body);

Five of the six fields are wrong, each in its own way, and one is missing outright: there is no phone. A reader would reject them one call at a time. The validator says all of it in one answer.

The validator

using SafeCoder.CommonKit;

public static class SignupValidator
{
    public static Dictionary<string, string> Validate(Dictionary<string, object?> payload)
    {
        Dictionary<string, string> problems = new(StringComparer.Ordinal);

        // a field is reported once: a missing key stays "required", a present value gets its reason
        void Judge(string key, string reason)
        {
            if (reason.Length > 0 && problems.ContainsKey(key) == false)
            {
                problems[key] = reason;
            }
        }

        // 1. what must be there at all
        foreach (string key in CkDictionary.MissingKeys(payload, "name", "email", "country", "postalCode", "birthDate", "amount", "phone"))
        {
            problems[key] = "required";
        }

        // 2. each value, judged by the class that owns it
        Judge("name", CkText.Explain(Text(payload, "name")));
        Judge("email", CkEmail.Explain(Text(payload, "email")));
        Judge("country", CkCountry.Explain(Text(payload, "country")));
        Judge("phone", CkUS.Explain(
            input: Text(payload, "phone"),
            what: nameof(CkUS.Phone)
        ));
        Judge("birthDate", CkDate.Explain(CkDictionary.Value(
            dictionary: payload,
            key: "birthDate"
        )));
        Judge("amount", CkMoney.Explain(CkDictionary.Value(
            dictionary: payload,
            key: "amount"
        )));

        // 3. rules between fields: a postal code is only judged against a country that passed
        if (problems.ContainsKey("country") == false)
        {
            Judge("postalCode", CkIntl.Explain(
                input: Text(payload, "postalCode"),
                what: nameof(CkIntl.PostalCode),
                country: Text(payload, "country")
            ));
        }

        if (problems.ContainsKey("birthDate") == false
            && CkDate.IsAdult(CkDictionary.Value(dictionary: payload, key: "birthDate")) == false)
        {
            problems["birthDate"] = "must be 18 or older";
        }

        return problems;
    }

    // the raw text of a field — a number in the JSON becomes its digits, a missing field ""
    private static string Text(Dictionary<string, object?> payload, string key)
        => CkDictionary.String(
            dictionary: payload,
            key: key
        );
}

Read it with the three moves in mind:

  • MissingKeys first, so an absent field says "required" rather than the "no value" a reader would say about it. Judge keeps the first reason a field gets, which is why the order matters.
  • Text readers take text, value readers take the value. CkText, CkEmail, CkCountry, CkUS and CkIntl judge a string, so Text pulls the field out with CkDictionary.String — a number in the JSON becomes its digits, a missing or null field becomes "", which Explain calls "no value". CkDate, CkMoney and CkNumber judge an object?, so they take CkDictionary.Value as it is: a JSON number stays a number and a DateTime stays a DateTime.
  • A dependent check waits for its input to pass. The postal code is judged against the country only when the country itself passed, or the reason would be about the country twice. Same for the age rule.

What it answers

Dictionary<string, string> problems = SignupValidator.Validate(payload);

For the payload above, every problem at once:

{
  "phone": "required",
  "name": "no value",
  "email": "no @ in the value",
  "postalCode": "not the shape of a postal code of Canada",
  "birthDate": "a two-digit year is ambiguous",
  "amount": "letters in the amount"
}

Each reason is the reader's own — the same words CkEmail.Address(value, throwIfInvalid: true) would carry — so the message beside the field and the exception in the log never disagree.

Using it at the gate

In an ASP.NET minimal API the dictionary is the whole 400 response:

app.MapPost("/signup", async (HttpRequest request) =>
{
    string body = await new StreamReader(request.Body).ReadToEndAsync();
    Dictionary<string, object?> payload = CkPayload.FromJson(body);

    Dictionary<string, string> problems = SignupValidator.Validate(payload);

    if (problems.Count > 0)
    {
        return Results.BadRequest(problems);
    }

    // past the gate the readers cannot reject: the validator judged by their rules
    string email = CkEmail.Address(CkDictionary.String(dictionary: payload, key: "email"));
    string postalCode = CkIntl.PostalCode(
        input: CkDictionary.String(dictionary: payload, key: "postalCode"),
        country: CkDictionary.String(dictionary: payload, key: "country")
    );

    return Results.Ok();
});

That last comment is the point of the design. Explain and the reader share one set of rules, so once the validator says nothing is wrong, reading the values is a formality — no second round of checks, no try/catch, no fallback that quietly hides a bad row.

Optional fields

A field that may be absent is judged only when it is there:

if (CkDictionary.Has(payload, "note"))
{
    Judge("note", CkText.Explain(Text(payload, "note")));
}

Has answers whether the key exists at all, a JSON null included; a present-but-null note then says "no value", which is what you want when the client sent the key on purpose.

Why not throwIfInvalid

throwIfInvalid: true is right when one bad value should stop everything: an import, a test, a job that must not continue. It stops at the first problem, by design. A validator wants the opposite — every problem, and no exception in the flow of an ordinary request — which is why it is built on Explain and the validators, none of which throw.

Keep one validator per payload

Put the field list in one place and test it with a bad payload:

[Fact]
public void Validate_SaysEverything_AtOnce()
{
    Dictionary<string, object?> payload = CkPayload.FromJson("""{ "email": "junk", "country": "Atlantis" }""");

    Dictionary<string, string> problems = SignupValidator.Validate(payload);

    Assert.Equal("no @ in the value", problems["email"]);
    Assert.Equal("not a recognised country or code", problems["country"]);
    Assert.Equal("required", problems["name"]);
    Assert.False(problems.ContainsKey("postalCode"));   // not judged: the country failed
}

The readers downstream then need no field knowledge of their own — they read what the validator let through.

Where next

  • Your first payload — a fifteen-minute tutorial: a messy order payload to clean, typed values, and the whole handler at the end.
  • Using it with EF Core — cleaning getters, one save hook, the query rule, column lengths.
  • CkDictionary — Value, String, Has and MissingKeys, and the dotted paths every reader takes.