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:
- What must be there at all.
CkDictionary.MissingKeysanswers the required keys that are absent — present-but-blank does not count as missing, the next move judges that. - 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 wordsthrowIfInvalid: truewould put in the exception — and it never throws, not even with the throw switch on. - Rules between fields. The validators do those:
CkDate.IsBeforefor a start and an end date (CkDateTime.IsBeforewhen the two carry a time),CkDate.IsAdultfor a birth date,CkIntl.PostalCodewith 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:
MissingKeysfirst, so an absent field says "required" rather than the "no value" a reader would say about it.Judgekeeps the first reason a field gets, which is why the order matters.- Text readers take text, value readers take the value.
CkText,CkEmail,CkCountry,CkUSandCkIntljudge astring, soTextpulls the field out withCkDictionary.String— a number in the JSON becomes its digits, a missing or null field becomes"", whichExplaincalls "no value".CkDate,CkMoneyandCkNumberjudge anobject?, so they takeCkDictionary.Valueas it is: a JSON number stays a number and aDateTimestays aDateTime. - 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,HasandMissingKeys, and the dotted paths every reader takes.