Primitives
The schemas you start a chain from — text, numbers, identifiers, dates, booleans, enums — and the rules that hang off each.
Every chain starts at a primitive. The primitive fixes the type; the rules after it narrow what that type is allowed to hold.
Text
Schema.Text accepts a string. Start with Trim() wherever the value came off a form — a trailing space is not something you want to reject over, and it is not something you want to store either.
public static readonly Schema<string, string> Sigil =
Schema.Text.Trim().LengthBetween(3, 24);
// A shape with a fixed width says so, rather than bounding both ends at the
// same number.
public static readonly Schema<string, string> CountryCode =
Schema.Text.Trim().Length(2);
// A closed set of spellings. Schema.Enum is the better home when the domain
// already models the set as an enumeration.
public static readonly Schema<string, string> Difficulty =
Schema.Text.Trim()
.OneOf(
global::System.StringComparison.OrdinalIgnoreCase,
"easy",
"standard",
"deadly");Length rules read as what they are. Length(2) is a fixed width; LengthBetween, MinLength and MaxLength are bounds; NotEmpty is the one you will reach for most.
Patterns
Matches takes a Regex, not a pattern string. That is on purpose: it puts the choice of a match timeout in front of you rather than behind you.
Always give the expression a timeout. The pattern is yours, but the value is not. An expression with no ceiling runs against a crafted input for as long as that input takes to defeat it.
Shapes with names
Some shapes are common enough to have their own rule. Each one is checked by a scan rather than an expression, so there is no pattern to get subtly wrong.
Url() with no scheme accepts more than you think. An absolute URL includes javascript:, data: and file:. Restrict the scheme whenever the value will be followed or rendered — which is nearly always. Passing an empty scheme list accepts nothing at all.
StartsWith and EndsWith take literals, not expressions. A dot or a bracket in one means itself.
Numbers
Four number schemas, one per type: Int32, Int64, Decimal and Double.
Inclusivity is in the name, and it matters.
GreaterThan, LessThan
Excluded
AtLeast, AtMost
Included
Between
Both included
Positive, Negative
Excludes zero
Prefer one rule over two where one says the same thing. Between(1, 6) reports a party of twelve as one failure; AtLeast(1).AtMost(6) reports the same thing and is just longer to read.
Identifiers
Schema.Uuid accepts a Guid. NotEmpty() rejects Guid.Empty, which is what an uninitialised field deserializes to and is almost never a value you meant to receive.
It is named for the standard rather than the role, because its rules are about the UUID layout. An identifier that is not a UUID starts at Schema.For<T>().
Checking the version
IsVersion4() requires the value to have been generated at random, which is what Guid.NewGuid() produces. Use it where the identifier must carry nothing a reader can mine — no creation time, and no ordering someone could walk.
IsVersion7() requires the other way round: version 7 leads with a millisecond timestamp, so a run of them sorts by creation order. That is what makes it a good database key and a bad choice where the creation time is a secret.
Both read the version digits and nothing else. A value with them set to 4 passes even if the rest was not random — nothing in a UUID records how it was really made. Both also reject Guid.Empty, whose version digits are zero, so adding NotEmpty() alongside one of them says nothing new.
Dates and times
Two schemas, and picking between them is picking what the value means.
Schema.Timestampis aDateTimeOffset— a moment, so a time zone is part of the value.Schema.Dateis aDateOnly— a day, so a time of day would be noise.
Schema.Date is not available on netstandard2.0, because DateOnly is not.
Inclusivity is in the name here too. Before and After exclude the bound; OnOrBefore and OnOrAfter include it, which is what a closing date means.
Booleans
IsFalse is worth a second look when you write it. A flag that has to be clear usually reads better as the opposite flag that has to be set.
Enums
Schema.Enum<T>() rejects a value outside the declared members. That is not a theoretical case — a cast produces one, and so does a deserializer handed a number.
Anything else
Schema.For<T>() is the identity schema. It accepts any value of that type and gives Check and Transform somewhere to hang off.
Every primitive above is a Schema.For<T>() with rules already attached. Reach for the bare form when your type has none — or when you want a rule over a whole subject, which is how cross-field rules work.
Last updated
Was this helpful?