Common mistakes
Every trap on this page follows logically from the five rules — but each one has caught someone anyway. Read them once now, or debug them one at a time later. Each section names the mistake, shows the wrong assumption next to the right call, and says why in a sentence or two.
A rejected calculation is zero
Every CkMoney calculation answers its fallback when a value cannot be used, and the
fallback is 0:
CkMoney.Multiply("19.99", "8.25%"); // -> 0 ("8.25%" is not a plain number)
On a form that is a convenience. In a checkout it is an order for nothing. Wherever a wrong
number costs money, pass throwIfInvalid: true — Taking a payment
does so on every line, and shows the percentage read the right way.
A bare number is not a percent
CkNumber.Percent reads values as rates — the fraction you
multiply by. A trailing % divides by 100; a value without one is taken as the rate
it already is:
CkNumber.Percent("15%"); // -> 0.15
CkNumber.Percent("0.15"); // -> 0.15 — a bare value is the rate as it stands
CkNumber.Percent(15); // -> 15 — that is 1500%!
The method cannot guess that your 15 meant fifteen percent — 0.15 is also a
perfectly good bare rate. Only the % mark says "divide me".
"published" is not false
The boolean readers — CkBool, and
CkDictionary.Bool, which reads through it — accept a real
bool, a word from a declared list (true/t/yes/y/on and
false/f/no/n/off, any casing), or a number that is exactly 1 or 0. Anything
else is unreadable, and unreadable means you get the fallback, not false:
var flags = CkPayload.FromJson("""{ "visible": "yes", "status": "published" }""");
CkDictionary.Bool(
dictionary: flags,
key: "visible"
); // -> true — "yes" is on the list
CkDictionary.Bool(
dictionary: flags,
key: "status"
); // -> false — the fallback, not a reading
CkDictionary.Bool(
dictionary: flags,
key: "status",
fallback: true
); // -> true — same unread value, other fallback
A false from Bool can mean "the value said no" or "the value said nothing I
recognise". When that difference matters, read the entry with the OrNull twin, which
answers null for anything unreadable — and when the column holds your own status words,
declare them:
CkBool.FromValueOrNull(CkDictionary.Value(
dictionary: flags,
key: "status"
)); // -> null — unknown stays unknown
CkBool.FromText(
input: CkDictionary.Value(dictionary: flags, key: "status"),
trueWords: ["published"],
falseWords: ["draft", "archived"]
); // -> true — read, by your own vocabulary
The same goes for numbers: 2 is not true. Only exactly 1 and 0 are booleans, so a count
that lands in a flag column is caught instead of becoming a confident yes.
"$5" and "1,234" are not numbers
CkNumber reads plain invariant numbers — digits, one decimal
point, an optional sign. Currency symbols and digit grouping (the commas in 1,234)
are money dressing, and money is CkMoney's job:
CkNumber.Int32("1,234"); // -> 0 — rejected, the default fallback
CkNumber.Decimal("$5"); // -> 0 — rejected
CkMoney.Amount("$5"); // -> 5
CkMoney.Amount("$1,234.56"); // -> 1234.56
The split is deliberate: a comma in a "number" is ambiguous (grouping in the US,
decimals in Europe), so CkNumber refuses it and CkMoney.Amount resolves it with
its mark parameter.
Zero means missing until you say otherwise
The OrNull readers treat a sentinel — a value that parses but really means
"nobody filled this in" — as missing. For numbers and money, the sentinel is zero,
and the treatment is on by default:
CkNumber.Int32OrNull("0"); // -> null — zero reads as "nobody filled this in"
CkNumber.Int32OrNull(
input: "0",
treatZeroAsMissing: false
); // -> 0 — you said zero is real
When zero is a legitimate value — a balance, a count, a temperature — switch the flag
off. Each class names its own: treatZeroAsMissing on numbers and money,
treatSentinelsAsMissing on dates, treatZerosAsMissing on UUIDs — and on
CkSentinel.ToNull, which applies the same verdicts on the
way to storage.
Raw ToString() writes whatever the machine's language says
ToString() obeys the machine's culture — its language and formatting settings.
Deploy to a server set to German or French and your decimal points silently become
commas:
CultureInfo.CurrentCulture = new CultureInfo("de-DE"); // pretend: a German server
(1234.5).ToString(); // -> "1234,5" — a comma, corrupt in JSON
new DateOnly(2026, 9, 2).ToString(); // -> "02.09.2026" — not ISO
CkText.NumberToString(1234.5); // -> "1234.5" — same on every machine
CkDate.Iso8601(new DateOnly(2026, 9, 2)); // -> "2026-09-02"
Anything leaving your process — payloads, CSVs, log lines you plan to parse — goes
through CkText.NumberToString,
CkDate.Iso8601 for a day and
CkDateTime.Iso8601 for a moment. That includes printing DateOnly values while
debugging: what you see in the console is culture-formatted too.
A rejected time looks exactly like midnight
Times have no safe "obviously wrong" value the way dates have year one: plain
CkDateTime.ToTimeOnly rejects to TimeOnly.MinValue, which is
00:00 — a time that really happens, once a day:
CkDateTime.ToTimeOnly("not a time"); // -> 00:00 — TimeOnly.MinValue, the rejection
CkDateTime.ToTimeOnly("00:00"); // -> 00:00 — a real midnight, identical
CkDateTime.ToTimeOnlyOrNull("not a time"); // -> null — clearly rejected
CkDateTime.ToTimeOnlyOrNull("00:00"); // -> 00:00 — clearly real
Use the plain reader only when a wrong-but-harmless midnight is acceptable.
ToTimeOnlyOrNull is the honest time reader: null and 00:00 can never be
confused.
Querying only for "" when the database also holds NULL
Once the CkSentinel.ToNull save hook
is in place, new saves write NULL where older rows hold "" — the two live side by side
in the same column, so a query for "missing" must ask for both:
var wrong = context.Members.Where(m => m.Referrer == "");
var missing = context.Members.Where(m => m.Referrer == null || m.Referrer == "");
Nothing errors to warn you when you check only one form. The whole pattern, and why a value converter cannot do this for you, is on Using it with EF Core.
A mask is not a validator
The display masks — CkUS.SsnMask and friends — exist to put
something safe on screen, so they never return empty. An invalid value comes
back fully masked, indistinguishable at a glance from a masked real one:
CkUS.SsnMask("garbage"); // -> "***-**-****" — fully masked, not empty
CkUS.Ssn("garbage"); // -> "" — empty means rejected
CkUS.Ssn("123-45-6789"); // -> "123-45-6789"
CkUS.SsnMask("123-45-6789"); // -> "***-**-6789"
"Non-empty in, empty out means rejected" does not apply to masks — they opted out of
that rule on purpose. When you need to know whether a value is valid, call the reader
(Ssn, Phone, …) and check for "", ask its yes-or-no twin (IsSsn, IsPhone, …), or
ask Explain for the reason.
A false verdict has no reason
The validators — IsSsn, IsAddress, IsDate and their siblings — answer false for a
value that is wrong and for one that is unusable: missing, blank, markup with nothing in
it. One bool cannot say which, and it was never meant to. When the difference matters,
ask Explain, which answers the reason in the reader's own words and "" when the value
passes:
CkEmail.IsAddress(""); // -> false
CkEmail.IsAddress("mary.smith"); // -> false — the same false
CkEmail.Explain(""); // -> "no value"
CkEmail.Explain("mary.smith"); // -> "no @ in the value"
A form shows the reason; a job that must stop passes throwIfInvalid: true to the reader
and gets the same words in the exception. The validator's false is for code that only
needs the verdict — an if, a filter, a count.
Html extracts text — it does not make text safe
CkText.Html answers one question: "what does this markup
say?" Its output is plain text, and plain text can still contain <, > and
quotes — everything a browser needs to misread it as markup again:
CkText.Html("<b>Hello</b> <i>world</i>"); // -> "Hello world"
CkText.Html("5 < 6 and 7 > 3"); // -> "5 < 6 and 7 > 3" — "<" survives
XSS (cross-site scripting — smuggling script into a page through data) is prevented
by encoding at render time, in the page that displays the value. Html strips
markup with regular expressions, not a parser, and makes no safety promise; never
paste its output into a page as-is.
A ticked ASP.NET checkbox reads as false
ASP.NET's checkbox helpers render two fields with the same name: the checkbox itself,
with the value true, and a hidden field after it with the value false — so that an
unticked box still posts something. A ticked box therefore posts both:
agree=true&agree=false
CkPayload.FromForm keeps the last value of a repeated key —
its declared rule — so the ticked box reads as false. And the framework's own
Request.Form["agree"] joins the two into "true,false", which is not a boolean at all:
var form = CkPayload.FromForm("agree=true&agree=false");
CkDictionary.Bool(
dictionary: form,
key: "agree"
); // -> false — the hidden field won
CkBool.FromValueOrNull("true,false"); // -> null — unreadable
The framework's model binder copes by taking the first value, so do the same: read
Request.Form["agree"][0] and hand that to CkBool.FromValue. Or
render a plain <input type="checkbox"> with no hidden twin — then a ticked box posts its
value and an unticked one posts nothing, which
CkBool.FromTextOrNull reads as true or null.