Skip to content

How it works

Every class in CommonKit follows the same five rules. Learn them once and you know the whole library — and reading a value becomes one small decision, made once per value.

Rule 1 — nothing throws by default

You can pass anything to any method: null, an empty string, garbage text, the wrong type, a huge input. The method never crashes your app. It answers with a rejection value instead — a calm, predictable "this could not be used".

CkEmail.Address(null);            // -> ""  (no crash)
CkEmail.Address("not an email");  // -> ""
CkNumber.Int32("banana");         // -> 0   (the default fallback)
CkUuid.ToGuid("oops");            // -> Guid.Empty

The throw switch

Every throwIfInvalid parameter defaults to CkSettings.DefaultThrowIfInvalid, a constant that ships as false. Set it to true and it overrides every call — throwIfInvalid: false included — so every rejected value surfaces at once as a Ck<Class>Exception carrying the input, what was expected and the reason, instead of slipping by as a fallback nobody checked. That is a development switch: set it true while an app is being built, by you or by an AI agent, and back to false to ship. It is a constant, so it takes a rebuild of the library, which is why it is for teams that build from the source. Two things it never touches: Explain (Rule 4) and the …OrNull readers (Rule 3) — the reason and null are their whole answers.

Rule 2 — the rejection value depends on the return type

Every value you read — a quantity, a birthday, a price — is one of three calls: the plain method, its …OrNull twin, or the plain method with throwIfInvalid: true. All three clean and validate the value exactly the same way; the only difference is what comes back when the value is bad. One table settles it:

Your situation Call Bad input gives
I need a value no matter what — a non-nullable property, and a default is fine the plain method, with a fallback your fallback
The column is nullable / I need to know whether it was really there the …OrNull twin null
Bad data must STOP the show — an import, a job that must not continue the plain method with throwIfInvalid: true that class's exception
I want the reason in words — a form, a validator that reports every problem the class's Explain the reason, "" when the value passes

Two words worth defining once: a non-nullable type cannot hold null — an int, a DateOnly. A nullable type wears a ? — int?, DateOnly? — and can. Databases call a missing value NULL, so nullable columns and the …OrNull twins go together.

What the plain method answers depends on what it returns:

You call… Bad input gives you…
a string method (CkText.String, CkEmail.Address, …) "" — an empty string
a number or money method your fallback parameter (default 0)
any …OrNull method null
CkUuid.ToGuid Guid.Empty
a date reader (CkDate.ToDateOnly, CkDateTime.ToDateTime) year one — DateOnly.MinValue, DateTime.MinValue
a geo reader the North Pole (90, 0); geo calculations give NaN
a list or byte[] method an empty list/array — never null
a display mask (SsnMask, CkEmail.Mask, …) a safe placeholder — never empty

For string methods this gives you a simple test: non-empty in, empty out means the value was rejected. Check for "" at the call site when it matters.

The plain method is for code that needs something to work with either way and a sensible default is fine. An order quantity, say — if the field is missing or unreadable, treating it as one item is a reasonable choice, and the program moves on:

int quantity = CkNumber.Int32(
    input: raw,
    fallback: 1
);

CkNumber.Int32(input: "3", fallback: 1);        // -> 3
CkNumber.Int32(input: "", fallback: 1);         // -> 1  (missing — the fallback steps in)
CkNumber.Int32(input: "banana", fallback: 1);   // -> 1  (unreadable — same answer)

No if, no try, no null — the answer is always an int you can use. The cost: you cannot tell a real 1 from a fallback 1. When that difference matters, pick one of the other calls. The fallback goes through the exact same cleaning and validation as the input; a bad fallback is rejected too. When you need to detect a rejection through a fallback, choose one you can distinguish from real data — say -1 for a quantity that is never negative.

CkCountry.Alpha2(input: dirty, fallback: "US");   // -> "US" when dirty is unusable
CkNumber.Int32(input: "banana", fallback: -1);    // -> -1

Rule 3 — when you want null, use the OrNull family

Databases have NULL; the rejection values above are not NULL. So the readers that feed nullable columns come in a second form: Int32OrNull, AmountOrNull, ToDateTimeOrNull, ToDateOnlyOrNull, ToTimeOnlyOrNull, ToGuidOrNull, LatitudeOrNull, LongitudeOrNull — and thirteen more numeric ones. Use one when "nothing" is a real, honest answer you want to keep. An optional birthday, say — some people gave one, some did not, and the nullable column can say which:

DateOnly? birthday = CkDate.ToDateOnlyOrNull(raw);

CkDate.ToDateOnlyOrNull("1985-04-12");   // -> 1985-04-12
CkDate.ToDateOnlyOrNull("");             // -> null  (missing)
CkDate.ToDateOnlyOrNull("junk");         // -> null  (unreadable)
CkDate.ToDateOnlyOrNull("1900-01-01");   // -> null  (a placeholder date — "nobody filled this in")

One promise comes with the family: an OrNull method never throws — at all. It does not even take a throwIfInvalid parameter, and the throw switch does not reach it. Whatever goes wrong — a missing value, garbage text, a placeholder — null is the answer, because null is exactly what you asked for by choosing this reader. When you want errors instead, use the plain sibling (Int32, Amount, ToDateTime, …) with throwIfInvalid: true.

They answer null for three kinds of input:

  1. A missing value — null or DBNull.
  2. An unreadable value — "banana" is not a number.
  3. A sentinel — a value that technically parses but really means "nobody filled this in": the number 0, placeholder dates like 1900-01-01, the all-zeros UUID. This is on by default and switchable per call — each class names its own flag (treatZeroAsMissing: false for numbers and money, treatSentinelsAsMissing: false for dates, treatZerosAsMissing: false for UUIDs).
CkNumber.Int32OrNull("42");                          // -> 42
CkNumber.Int32OrNull("");                            // -> null (missing)
CkNumber.Int32OrNull("0");                           // -> null (zero = sentinel)
CkNumber.Int32OrNull(input: "0",
    treatZeroAsMissing: false);                      // -> 0 (you said zero is real)
CkDateTime.ToDateTimeOrNull("1900-01-01");           // -> null (placeholder date)

A yes-or-no value is where this family earns its keep, because a plain bool has no room for "unknown": a missing consent answer would be stored as a refusal. CkBool.FromValueOrNull keeps the three apart — and a boolean has no sentinel to watch for: false is always something somebody said.

bool? optIn = CkBool.FromValueOrNull(raw);

CkBool.FromValueOrNull("yes");     // -> true
CkBool.FromValueOrNull("no");      // -> false  (declined — a real answer)
CkBool.FromValueOrNull("");        // -> null   (never answered)

Strings are different

String methods have no OrNull twins — with one exception, CkText.ToStringOrNull, the nullable version of free-text cleaning:

CkText.ToStringOrNull(" hi  there ");   // -> "hi there"
CkText.ToStringOrNull("   ");           // -> null  (nothing left once cleaned)

For every other string method — CkEmail.Address, CkUS.Phone, CkText.Name, … — the rejection value is "", and the bridge to null is CkText.NullIfEmpty:

string? email = CkText.NullIfEmpty(CkEmail.Address(raw));

CkText.NullIfEmpty(CkEmail.Address(" Mary.Smith@GMAIL.com "));  // -> "mary.smith@gmail.com"
CkText.NullIfEmpty(CkEmail.Address("not an email"));            // -> null

Read it inside-out: CkEmail.Address cleans and validates, answering "" for a bad address; CkText.NullIfEmpty turns that "" into the null your nullable column wants. Two small calls, one honest value. Saving through EF Core, one hook can do this for every property at once — CkSentinel.ToNull, wired up on Using it with EF Core.

Rule 4 — when you want errors, ask with throwIfInvalid

Sometimes silence is wrong: an import that must not load garbage, a job that must stop at the first bad row, a test. Every cleaning and converting method takes throwIfInvalid: true. Then a value that cannot be used raises that class's own exception — CkTextException, CkNumberException, CkDateException, … — all sealed, all deriving from CkException, so one catch (CkException) covers them all when you want a single net. Every one carries the Input that failed and a short Reason:

using SafeCoder.CommonKit.Exceptions;

foreach (string raw in importRows)
{
    try
    {
        decimal amount = CkMoney.Amount(
            input: raw,
            throwIfInvalid: true
        );
        // ... save the amount ...
    }
    catch (CkMoneyException error)
    {
        Console.WriteLine(error.Message);   // one log-safe line: the method, the input, the reason
        Console.WriteLine(error.Reason);    // short and lowercase — for raw = "ten dollars": letters in the amount
    }
}

The row that says "ten dollars" stops its loop iteration with a reason you can log or show an analyst, instead of quietly becoming 0.00 in the database.

A few members have nothing to throw about and take no flag: the display masks, the counters (WordCount, CharacterCount), CkText.Join / Split / SameText / Similarity, the In… allow-list checks (CkCountry.InCountries, CkMoney.InCurrencies, …), CkDictionary.Has / MissingKeys, CkSentinel.ToNull — and Explain.

Explain — the reason without the exception

An exception stops at the first problem. A form, or a validator that reports everything wrong with a payload, wants the reason for each field and no exception in the flow. Every class with a reader has an Explain for that — CkEmail.Explain(input), CkDate.Explain(input), CkUS.Explain(input, nameof(CkUS.Ssn)) where a class has several readers. The same rules, the same words the exception would carry, "" when the value passes; it never throws, not even with the throw switch on.

CkMoney.Explain("ten dollars");   // -> "letters in the amount"
CkMoney.Explain("$19.99");        // -> ""

Writing a validator builds a whole-payload check out of it.

Rule 5 — write data out the invariant way

Reading is half the job; the other half is writing values out — into JSON, a CSV, an API call. Raw ToString() obeys the machine's locale: on a German or French server, (1234.5).ToString() is "1234,5", and that comma quietly corrupts your payload.

CommonKit's writers always use the invariant culture — the same output on every machine:

CkText.NumberToString(1234.5);                 // -> "1234.5"  (never "1234,5")
CkBool.Format(true);                           // -> "true"    (what JSON, XML and URLs expect)
CkDateTime.Iso8601(new DateTime(2026, 9, 2, 10, 30, 0, DateTimeKind.Utc));
                                               // -> "2026-09-02T10:30:00Z"
CkDate.Iso8601(new DateOnly(2026, 9, 2));      // -> "2026-09-02"

A boolean has more than one written form, so CkBool.Format takes the spelling: lowercase true/false by default, 1/0 for a database flag, Y/N for a legacy column. (.NET's own True — what true.ToString() and CkText.BoolToString write — is not valid JSON.)

Rule of thumb: data leaving your process goes through a CommonKit writer, never raw ToString().

Behind the rules

  • Everything is length-capped. A ten-megabyte "name" is rejected, not processed. Every cap and default is a public constant in CkSettings (for example CkSettings.MaxTextInputLength), so you can read the limits in one place. A cap counts content, not padding: a CHAR(255) column hands "42" back followed by 253 spaces, and CkNumber.Int32OrNull still reads 42.
  • Every method is static, pure and thread-safe. No setup, no state, no service to inject — call from anywhere.
  • The invariant culture is used everywhere, reading and writing. A comma-decimal locale on the server changes nothing.
  • Accents are composed on the way in. A base letter plus a combining mark, the way macOS writes "é", becomes the one composed letter in every reader of every class, so it counts, compares and stores like text typed anywhere else.

Where next