Skip to content

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.