SaddamHossain.Toolkit.Validation
1.0.0
dotnet add package SaddamHossain.Toolkit.Validation --version 1.0.0
NuGet\Install-Package SaddamHossain.Toolkit.Validation -Version 1.0.0
<PackageReference Include="SaddamHossain.Toolkit.Validation" Version="1.0.0" />
<PackageVersion Include="SaddamHossain.Toolkit.Validation" Version="1.0.0" />
<PackageReference Include="SaddamHossain.Toolkit.Validation" />
paket add SaddamHossain.Toolkit.Validation --version 1.0.0
#r "nuget: SaddamHossain.Toolkit.Validation, 1.0.0"
#:package SaddamHossain.Toolkit.Validation@1.0.0
#addin nuget:?package=SaddamHossain.Toolkit.Validation&version=1.0.0
#tool nuget:?package=SaddamHossain.Toolkit.Validation&version=1.0.0
SaddamHossain.Toolkit.Validation
A lightweight, dependency-free validation library for modern .NET — 29 validators, no regular expressions, and a contract that never throws.
Version 1.0.0 — the initial release. 29 validators across e-mail, telephone, URL, password, network, identity, file type and structured data, each callable as a static method or as a string extension. See the changelog for the full list with the reasoning behind each design decision.
Contents
Overview · Features · Installation · Quick start · The contract · Validator categories · Code examples · Requirements · Performance · Architecture · Roadmap · Contributing · Security · Versioning · License · Links
Overview
Every .NET codebase accumulates the same small validation helpers: is this an e-mail address, is
this a URL, is this JSON, is this card number plausible. They get copied between projects, drift
apart, and are almost always built the same wrong way — a regular expression found on the internet,
which either rejects o'brien@example.com or accepts a@b.
SaddamHossain.Toolkit.Validation is that layer, done once and done properly. The organising idea
is simple and it runs through every method here:
Use the right primitive for each job, and state plainly what the answer does not mean.
So credit cards get the Luhn algorithm rather than a length check. IP addresses get
IPAddress.TryParse. URLs get Uri.TryCreate plus the four decisions RFC 3986 deliberately leaves
to the application. JSON gets a streaming Utf8JsonReader rather than a document object model.
XML gets an XmlReader hardened against the entity attacks that make parsing untrusted XML a
security operation. And where a standard defines a grammar over ASCII — e-mail, host names, Base64,
JWT — it gets a hand-written character scan, which is what a regex engine would be interpreting
anyway, minus the pattern compilation, the backtracking and the catastrophic case nobody notices
until an attacker finds it.
There are no regular expressions anywhere in this package.
Features
- Two ways to call everything.
Validator.IsEmail(value)reads well in a guard clause;value.IsEmail()reads well in a chain. Every extension method is a one-line forwarder to theValidatormethod, so there is exactly one implementation of every rule — a reflection test proves the two styles agree for all 29 pairs, and that neither has a method the other lacks. - Never throws. Every validator is a total function over
string?. No guard clauses at your call site, notryblock, noArgumentNullException.nullisfalse. - Zero dependencies. The package references no other NuGet package. Everything it uses ships in the shared framework, and an assembly test asserts that so a dependency cannot be added quietly.
- No regular expressions, no reflection, no
unsafe, nodynamic. MarkedIsAotCompatible, so Native AOT and trimmed publishes stay warning-free. - Allocation-conscious.
ReadOnlySpan<char>throughout,stackallocbelow 512 bytes andArrayPoolabove it. Most validators allocate nothing at all, including the domain list lookups — see Performance. - Multi-targeted for
net8.0,net9.0andnet10.0, with the whole test suite running against each.System.Text.Json,IPAddress.TryParseandXmlReaderhave all changed their strictness between releases, so a green net10.0 run alone is not evidence the net8.0 package works. - Honest about limits. Syntax is not deliverability, Luhn is not fraud detection, an extension is not a file format, and a JWT's structure is not its signature. Every method whose answer is easy to over-trust says so in its own documentation, at the top, not in a footnote.
- Fully documented. Every public member ships XML documentation with parameters, return values, remarks and a worked example. The entire public API surface is pinned by a test.
- Source Link + symbols. Step straight into the source from your debugger.
Installation
dotnet add package SaddamHossain.Toolkit.Validation
Or via the Package Manager Console:
Install-Package SaddamHossain.Toolkit.Validation
Or directly in the project file:
<PackageReference Include="SaddamHossain.Toolkit.Validation" Version="1.0.0" />
Nothing else is needed. The package pulls in no transitive dependencies, so it cannot introduce a version conflict with anything already in your graph.
Quick start
One using makes both call styles discoverable through IntelliSense:
using SaddamHossain.Toolkit.Validation;
// Static style — reads well as a guard clause.
if (!Validator.IsEmail(model.Email))
{
return Results.BadRequest("That does not look like an e-mail address.");
}
// Extension style — reads well in a chain or an expression.
if (model.Email.IsDisposableEmail())
{
return Results.BadRequest("Please use a permanent address.");
}
// Null-safe throughout: no guard clause needed before either call.
string? fromQueryString = Request.Query["website"];
if (fromQueryString.IsHttpsUrl())
{
profile.Website = fromQueryString;
}
The password band is the one API with a richer return type, because a strength meter needs more than a boolean:
using SaddamHossain.Toolkit.Validation.Models;
PasswordStrength strength = model.Password.GetPasswordStrength();
if (strength < PasswordStrength.Medium)
{
return Results.BadRequest(strength == PasswordStrength.None
? "Please choose a password."
: "That password is too easy to guess.");
}
The contract
Five properties hold for every validator in the package. They are documented on Validator,
implemented in one place, and asserted for all 29 at once by a table-driven test — so adding a
validator without honouring them fails the build.
| Total | Never throws, for any input, including null. |
| Absent is invalid | null, "" and white space are false everywhere, with no exceptions. |
| Nothing is trimmed | " a@b.com " is invalid. Trim where you can see it happening. |
| Pure | No state, no cache, no clock, no culture read, no I/O. Same answer on every machine. |
| Form, never existence | Nothing resolves DNS, opens a socket, or reads a file. |
Two families are exempt from the trimming rule, each for a stated reason rather than for
convenience: the password band, where a leading space is a character the user chose and
discarding it would classify a different secret; and IsJson and IsXml, whose grammars
define surrounding white space as part of the document.
Validator categories
29 validators, each available as Validator.X(value) and as value.X().
| Method | Summary |
|---|---|
IsEmail() |
RFC 5322 dot-atom at a registrable domain, RFC 5321 lengths |
IsDisposableEmail() |
Domain, or any parent of it, is a known throwaway provider |
IsBusinessEmail() |
Valid, not disposable, and not consumer webmail |
Telephone
| Method | Summary |
|---|---|
IsPhone() |
7–15 digits in any conventional notation |
IsBangladeshPhone() |
BTRC mobile plan, all four notations |
IsInternationalPhone() |
E.164 with an explicit + country code |
URL and domain
| Method | Summary |
|---|---|
IsUrl() |
Absolute URL on an allow-listed scheme with a real host |
IsHttpsUrl() |
The same, narrowed to https |
IsDomain() |
A registrable domain — two or more labels, plausible TLD |
Password
| Method | Returns | Summary |
|---|---|---|
GetPasswordStrength() |
PasswordStrength |
None / Weak / Medium / Strong |
IsPasswordStrong() |
bool |
12+ characters, all four character classes |
IsPasswordMedium() |
bool |
8+ characters, at least three classes |
IsPasswordWeak() |
bool |
Present, but neither of the above |
Network
| Method | Summary |
|---|---|
IsIPv4() |
Canonical dotted quad, no ambiguous leading zeros |
IsIPv6() |
Compression, embedded IPv4 and scope IDs, via IPAddress.TryParse |
IsMacAddress() |
EUI-48 in IEEE, Windows, Cisco or bare notation |
Identity
| Method | Summary |
|---|---|
IsGuid() |
All five Guid formats (N, D, B, P, X) |
IsCreditCard() |
12–19 digits and the Luhn mod-10 checksum |
IsNationalId() |
Generic shape: 5–20 alphanumerics, 4+ digits |
IsPassport() |
ICAO shape: 6–9 uppercase alphanumerics with a digit |
IsTaxNumber() |
Generic shape: 8–20 alphanumerics, 4+ digits |
IsNationalId, IsPassport and IsTaxNumber perform generic format validation only. No
country-specific rules are hard-coded, deliberately — see below.
File type
| Method | Extensions |
|---|---|
IsPdf() |
.pdf |
IsImage() |
.jpg .jpeg .jfif .png .gif .bmp .webp .tif .tiff .svg .ico .avif .heic .heif |
IsExcel() |
.xls .xlsx .xlsm .xlsb .xlt .xltx .xltm .xla .xlam |
IsWord() |
.doc .docx .docm .dot .dotx .dotm |
Structured data
| Method | Summary |
|---|---|
IsJson() |
RFC 8259 via streaming Utf8JsonReader, depth-capped |
IsXml() |
Well-formed via a XmlReader hardened against XXE |
IsBase64() |
RFC 4648 §4, stricter than Convert.TryFromBase64String |
IsJwt() |
RFC 7519 structure — not a signature check |
Code examples
"user@example.com".IsEmail(); // true
"first.last@sub.example.co.uk".IsEmail(); // true
"o'brien+tag@example.io".IsEmail(); // true — both are legal atom characters
"user@localhost".IsEmail(); // false — single-label domain
"user@[192.168.0.1]".IsEmail(); // false — address literal
"\"john doe\"@example.com".IsEmail(); // false — quoted local part
"us..er@example.com".IsEmail(); // false — consecutive dots
" user@example.com ".IsEmail(); // false — not trimmed, by design
Deliberately stricter than RFC 5322 in five places, each because the specification permits something
no signup form should store. If you need an internationalised address, encode the domain to punycode
and validate that — "user@example.xn--p1ai".IsEmail() is true.
Syntax is not deliverability. This catches typing mistakes. It cannot tell you that a perfectly formed address belongs to nobody, or to somebody else. Send the confirmation e-mail.
The two classifiers work on the domain, and match sub-domains too, because providers hand out per-user hosts precisely to defeat exact matching:
"user@mailinator.com".IsDisposableEmail(); // true
"user@alice.mailinator.com".IsDisposableEmail(); // true
"user@YOPMAIL.COM".IsDisposableEmail(); // true — case-insensitive
"user@gmail.com".IsDisposableEmail(); // false — free, but durable
"ceo@contoso.com".IsBusinessEmail(); // true
"user@gmail.com".IsBusinessEmail(); // false
IsDisposableEmail is a high-precision, low-recall filter: a true is reliable, a false proves
nothing — new providers appear weekly. And consider what you do with the answer. Blocking outright
turns away people with a legitimate reason to compartmentalise; raising a friction step or simply
recording the signal is usually the better product.
Telephone
"+1 (555) 123-4567".IsPhone(); // true
"+15551234567".IsPhone(); // true — the same number
"020 7946 0958".IsPhone(); // true — national form
"555.123.4567".IsPhone(); // true
"123456".IsPhone(); // false — 6 digits
"+1 (555 123-4567".IsPhone(); // false — unbalanced parentheses
"555-123-4567-".IsPhone(); // false — dangling separator
Digits are counted rather than positioned, but the punctuation still has to be well formed: at most one balanced parenthesis pair, enclosing at least one digit, and nothing dangling off either end.
Bangladeshi mobile numbers in all four notations:
"+8801712345678".IsBangladeshPhone(); // true
"008801712345678".IsBangladeshPhone(); // true
"8801712345678".IsBangladeshPhone(); // true
"01712345678".IsBangladeshPhone(); // true — national form
"+880 1712-345678".IsBangladeshPhone(); // true — formatting ignored
"+8801212345678".IsBangladeshPhone(); // false — operator 12 is unallocated
"1712345678".IsBangladeshPhone(); // false — ambiguous with no prefix
"+008801712345678".IsBangladeshPhone(); // false — "+" and "00" contradict
Operator digits 3–9 are allocated by the BTRC; 0, 1 and 2 are not, so a length check alone would wave through numbers that cannot exist.
"+44 20 7946 0958".IsInternationalPhone(); // true
"020 7946 0958".IsInternationalPhone(); // false — valid in several countries at once
"+0044207946095".IsInternationalPhone(); // false — trunk zero concatenated, not replaced
URL and domain
"https://example.com".IsUrl(); // true
"http://localhost:5000/api?q=1#top".IsUrl(); // true
"ftp://files.example.com/pub".IsUrl(); // true
"wss://example.com/socket".IsUrl(); // true
"https://[2001:db8::1]/health".IsUrl(); // true
"javascript:alert(1)".IsUrl(); // false ← the rule that earns its keep
"data:text/html;base64,PHNjcmlwdD4=".IsUrl(); // false
"file:///etc/passwd".IsUrl(); // false
"example.com".IsUrl(); // false — not absolute
"https://-bad-.com".IsUrl(); // false — malformed label
"https://exa mple.com".IsUrl(); // false — embedded space
Uri.TryCreate parses the first three of those rejected values perfectly happily. An href built
from a "validated URL" is how javascript: becomes stored cross-site scripting, which is why the
scheme allow-list exists.
Valid is not safe.
http://169.254.169.254/is a perfectly valid URL and the cloud metadata endpoint. If you are about to fetch a user-supplied URL rather than store or display it, resolve the host and refuse private, loopback and link-local addresses too.
IsUrl accepts a single-label host, because http://localhost:5000 is a real address every
developer types daily. IsDomain does not, and that is the entire distinction between them:
"sub.example.co.uk".IsDomain(); // true
"example.xn--p1ai".IsDomain(); // true — punycode TLD
"localhost".IsDomain(); // false — no top-level domain
"192.168.0.1".IsDomain(); // false — an address, not a domain
"example.com.".IsDomain(); // false — trailing dot
Password
"Tr0ub4dor&3xyz".GetPasswordStrength(); // Strong — 14 chars, 4 classes
"Password1".GetPasswordStrength(); // Medium — 9 chars, 3 classes
"password".GetPasswordStrength(); // Weak — 1 class
"Ab1!".GetPasswordStrength(); // Weak — 4 classes, too short
" ".GetPasswordStrength(); // None
The three predicates are mutually exclusive bands, not thresholds — a strong password is not also medium. That is the opposite of the usual reading and it is deliberate: overlapping booleans are how a strong password gets rejected by code that was testing for a medium one. When you mean "medium or better", say so with the enum:
if (candidate.GetPasswordStrength() >= PasswordStrength.Medium) { }
None is distinct from Weak on purpose — "the user typed nothing" and "the user typed something
bad" call for different messages.
Length counts Unicode scalar values, and caseless scripts count as letters rather than as punctuation:
"😀😀😀😀😀😀".GetPasswordStrength(); // Weak — 6 characters, not 12
"বাংলাভাষারপাসওয়ার্ডA1".GetPasswordStrength(); // Strong
Composition is not guessability, and the gap between them is
"P@ssw0rd1234"— twelve characters, all four classes, classifiedStronghere, and in the first thousand candidates any real attacker tries. NIST SP 800-63B §5.1.1.2 is explicit that screening against known-breached passwords does more for real security than any composition rule. Pair this with a check against the k-anonymity API atapi.pwnedpasswords.com, which never sees the password or its full hash. This classifier stops a user submitting something too short; the breach check tells them theirs has already leaked.
Network
"192.168.1.1".IsIPv4(); // true
"255.255.255.255".IsIPv4(); // true
"192.168.01.1".IsIPv4(); // false ← the rule that matters
"0177.0.0.1".IsIPv4(); // false
"256.1.1.1".IsIPv4(); // false
"192.168.1".IsIPv4(); // false
127.0.0.01 is decimal to one library and octal to the next, and 0177.0.0.1 is 127.0.0.1 to
anything following the C convention. An allow-list that disagrees with the socket layer about which
host a string names is not a nit — it is how a filter gets bypassed. Refusing to have an opinion on
ambiguous input is the only safe answer, and it is also what keeps IsIPv4 returning the same
verdict on all three target frameworks.
"2001:db8:85a3::8a2e:370:7334".IsIPv6(); // true
"::1".IsIPv6(); // true
"::ffff:192.168.0.1".IsIPv6(); // true — IPv4-mapped is IPv6
"fe80::1%eth0".IsIPv6(); // true — scoped
"[::1]".IsIPv6(); // false — URL authority syntax
"2001:db8::/32".IsIPv6(); // false — a CIDR block
IPv6 parsing is delegated wholesale to IPAddress.TryParse. :: compression that may appear
exactly once, IPv4 embedded in the low 32 bits, optional scope identifiers — hand-written IPv6
parsers are a well-documented source of parser-differential bugs, and this is not the place to write
another one.
"00:1A:2B:3C:4D:5E".IsMacAddress(); // true — IEEE
"00-1A-2B-3C-4D-5E".IsMacAddress(); // true — Windows
"001A.2B3C.4D5E".IsMacAddress(); // true — Cisco
"001a2b3c4d5e".IsMacAddress(); // true — bare
"00:1A-2B:3C-4D:5E".IsMacAddress(); // false — mixed separators
Identity
"d9b2d63d-a233-4123-847a-4e0b0e7c1f4d".IsGuid(); // true
"D9B2D63DA2334123847A4E0B0E7C1F4D".IsGuid(); // true — "N" format
"{d9b2d63d-a233-4123-847a-4e0b0e7c1f4d}".IsGuid(); // true — "B" format
"00000000-0000-0000-0000-000000000000".IsGuid(); // true — Guid.Empty is a GUID
" d9b2d63d-a233-4123-847a-4e0b0e7c1f4d ".IsGuid(); // false — unlike Guid.TryParse
Credit cards get the Luhn mod-10 checksum, not a format check:
"4111 1111 1111 1111".IsCreditCard(); // true — Visa test number
"5500-0000-0000-0004".IsCreditCard(); // true — Mastercard test number
"378282246310005".IsCreditCard(); // true — Amex, 15 digits
"4111111111111112".IsCreditCard(); // false — one digit wrong
"4111111111111121".IsCreditCard(); // false — two digits transposed
"0000000000000000".IsCreditCard(); // false — passes Luhn, leading zero
Luhn catches typos, not fraud. It detects every single-digit error and almost every transposition of adjacent digits — the mistakes a human makes copying sixteen digits off a card — and roughly one in ten random digit strings passes it anyway. It says nothing about whether the card exists, is active, or belongs to the person typing it.
Do not store what you validate here. A primary account number falls under PCI DSS the moment it exists in your process. Validate it, pass it to the payment provider, and let it go — a card number that never reaches your database cannot be leaked from it.
National ID, passport and tax number
These three perform generic format validation only. No country-specific rules are hard-coded, and that is a deliberate decision rather than an omission: there are close to two hundred national ID schemes, several hundred passport-issuing authorities and a tax format for every jurisdiction that levies tax. Many change every few years, and many carry checksums whose specification is not public. A package claiming to validate them all would be wrong about most of them, and would be wrong silently — refusing a citizen's genuine document because the format changed in 2019.
"19812345678901234".IsNationalId(); // true — Bangladesh NID, 17 digits
"123-45-6789".IsNationalId(); // true — grouped
"ABCDEFGH".IsNationalId(); // false — no digits
"ab1234567".IsNationalId(); // false — lowercase
"A1234567".IsPassport(); // true
"123456789".IsPassport(); // true — all-digit numbers are issued too
"a1234567".IsPassport(); // false — MRZ documents are uppercase
"A123-4567".IsPassport(); // false — separators are not printed
"123456789012".IsTaxNumber(); // true — Bangladesh eTIN
"12-3456789".IsTaxNumber(); // true — US EIN
"ABCDE1234F".IsTaxNumber(); // true — Indian PAN
"DE123456789".IsTaxNumber(); // true — EU VAT
What that buys you is real but bounded: it rejects an empty box, a name in the wrong field, a sentence, a symbol soup, and a number wildly too short or too long — which is most of what a form actually receives. It is not verification. Check the document with the authority that issued it.
File type
"report.pdf".IsPdf(); // true
"C:\\docs\\2026\\report.PDF".IsPdf(); // true — both separators, every platform
"/var/data/report.pdf".IsPdf(); // true
"report.pdf.exe".IsPdf(); // false — only the last extension counts
".pdf".IsPdf(); // false — a dot-file has no extension
"avatar.png".IsImage(); // true
"budget.xlsx".IsExcel(); // true
"export.csv".IsExcel(); // false — Excel opens it; it is not an Excel format
"contract.docx".IsWord(); // true
"notes.rtf".IsWord(); // false — same reasoning
An extension is a claim, not evidence. These answer "is this name claiming to be a PDF?" — right for routing an upload, choosing an icon, or filtering a listing, and wrong for deciding whether to open, parse or execute something. For that, read the magic bytes:
%PDF-for PDF,PK\x03\x04for the Office XML formats. That requires I/O, which this package deliberately never performs. Use both, at the two points where each is cheap.
.svgdeserves its own note: it is an image, and it is also an XML document that can carry script. An SVG accepted from a user and served from your own origin is a stored XSS vector.
Structured data
"""{"name":"Ada","age":36}""".IsJson(); // true
"[1, 2, 3]".IsJson(); // true
"42".IsJson(); // true — a bare scalar is a document (RFC 8259 §2)
"null".IsJson(); // true
"""{"a":1,}""".IsJson(); // false — trailing comma is JSON5, not JSON
"""{"a":1} // note""".IsJson(); // false — so are comments
"{'a':1}".IsJson(); // false — single quotes
"""{"a":1}{"b":2}""".IsJson(); // false — two documents
Parsed with a streaming Utf8JsonReader, not JsonDocument.Parse — the one-line alternative builds
an entire document object model to answer a yes/no question. Nesting is capped at 64 levels, because
untrusted JSON is an attack surface and a few kilobytes of [[[[[… will exhaust a recursive
parser's stack.
"<root/>".IsXml(); // true
"<a><b>text</b></a>".IsXml(); // true
"<a><![CDATA[<raw>]]></a>".IsXml(); // true
"<a></b>".IsXml(); // false — mismatched
"<a/><b/>".IsXml(); // false — two roots
"<!DOCTYPE a><a/>".IsXml(); // false — see below
Validating untrusted XML is a security operation, not a parsing one. The default configuration of almost every XML parser will, when handed a document it was asked only to check, resolve external entities — reading a file off the disk (XXE) or issuing a request to an attacker-chosen host (SSRF) — and will expand internal entities until the process dies (the "billion laughs" attack, which fits in 800 bytes). A validator that fell into either would be strictly worse than none, because it is invoked precisely on input nobody trusts.
The reader here prohibits DTD processing, uses a throwing resolver, and requires exactly one root element. The consequence, stated plainly: a document containing a
<!DOCTYPE>declaration is reported invalid, even when it is well formed. That is a real limitation and a deliberate trade.
"SGVsbG8gV29ybGQ=".IsBase64(); // true
"YWJj".IsBase64(); // true — no padding needed
"SGVsbG8".IsBase64(); // false — length is not a multiple of four
"SGVs bG8=".IsBase64(); // false ← stricter than Convert
"SGV=bG8=".IsBase64(); // false — padding in the middle
"SGVsbG8_".IsBase64(); // false — URL-safe alphabet
Convert.TryFromBase64String accepts the middle one: it silently ignores white space anywhere in
its input, which is correct for a decoder reading a MIME body and wrong for a validator asked "is
this field a Base64 value?" A token with a newline through the middle of it has been mangled in
transit, and calling it valid hides the corruption.
// {"alg":"HS256","typ":"JWT"} . {"sub":"1234567890"} . signature
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc".IsJwt(); // true
"eyJhbGciOiJub25lIn0.eyJzdWIiOiIxIn0.".IsJwt(); // true — the alg:none form
"eyJ0eXAiOiJKV1QifQ.eyJzdWIiOiIxIn0.abc".IsJwt(); // false — header declares no "alg"
"not.a.jwt".IsJwt(); // false
This is not a signature check. It proves the token is shaped like a JWT and says nothing about whether it is authentic, unexpired, issued by anyone you trust, or scoped to the operation being attempted. Anyone can mint a value that passes it in thirty seconds with a Base64 encoder.
The legitimate uses are the ones where the answer is not a security decision: rejecting a malformed
Authorizationheader before paying for a key-set fetch, telling a user their pasted token is truncated, or asserting in a test. For anything that grants access, validate properly — withMicrosoft.IdentityModel.JsonWebTokensor equivalent — against the issuer's keys, the expected audience, the algorithm you require, and the clock.
Requirements
| Runtime | .NET 8.0, .NET 9.0 or .NET 10.0 |
| Language | Any — the public surface is plain static methods and extension methods, so no C# version, F# or VB feature is required to consume it |
| OS | Any platform supported by .NET — no OS-specific code |
| Dependencies | None, on every target framework |
| Publishing | Trim- and Native AOT-safe (IsAotCompatible) |
| SDK (to build this repository) | .NET 10.0 SDK or later, plus the .NET 8.0 and 9.0 runtimes to execute the full test matrix |
Performance
Measured with BenchmarkDotNet 0.15.8 on .NET 10.0 — Intel Core i5-8500, Windows 11, ShortRun job. Run them yourself:
cd benchmarks/SaddamHossain.Toolkit.Validation.Benchmarks
dotnet run -c Release -f net10.0
The numbers worth quoting are in the Allocated column rather than the time column. Most validators here do their entire job without touching the heap — including the disposable-domain and free-provider lookups, which walk a candidate up its parent domains and binary-search a sorted list at each step, all over slices of the caller's string.
| Validator | Input | Mean | Allocated |
|---|---|---|---|
IsEmail |
null |
0.001 ns | 0 B |
IsEmail |
rejected, no @ |
3.9 ns | 0 B |
IsEmail |
first.last@sub.example.co.uk |
72 ns | 0 B |
IsEmail |
254 characters, the maximum | 293 ns | 0 B |
IsDisposableEmail |
hit — throwaway@mailinator.com |
116 ns | 0 B |
IsBusinessEmail |
miss on both lists | 141 ns | 0 B |
GetPasswordStrength |
Tr0ub4dor&3xyz |
29 ns | 0 B |
GetPasswordStrength |
74-character passphrase | 162 ns | 0 B |
IsDomain |
sub.example.co.uk |
37 ns | 0 B |
IsBase64 |
16 characters | 47 ns | 0 B |
IsBase64 |
8 KB payload | 22.6 µs | 0 B (pooled) |
IsJson |
36-character object | 120 ns | 0 B |
IsJson |
8 KB array | 30.9 µs | 0 B (pooled) |
IsUrl |
rejected before parsing (white space) | 12 ns | 0 B |
IsUrl |
https://example.com |
209 ns | 192 B |
IsUrl |
path, query and fragment | 439 ns | 200 B |
Four things about the shape of those numbers:
nullis free, and rejection is usually far cheaper than acceptance — which is the right way round for a public endpoint, where most of what arrives is invalid. Every validator's first act is onestring.IsNullOrWhiteSpace, andIsUrlrefuses a value with white space in it beforeUriis ever constructed.- Large inputs do not scale their allocation.
stackallocbelow 512 bytes,ArrayPoolabove it, returned in afinallyso a malformed document cannot leak the buffer. An 8 KB JSON document is validated with zero heap traffic;JsonDocument.Parsewould have allocated proportionally. IsUrlallocates on purpose.Uri.TryCreateconstructs aUri, and reimplementing RFC 3986 to avoid ~200 bytes would be a far worse trade than the object costs.IsDomainis the contrast in the same table: the same host, validated by the package's own scanner, at a sixth of the time and none of the memory.- Invalid JSON and XML cost an exception. This is the one place in the package where an invalid
input is more expensive than a valid one: rejecting malformed JSON costs about 2.8 µs and ~1 KB,
because
Utf8JsonReaderreports a syntax error by throwing and .NET has no non-throwing mode for it.IsXmlbehaves the same way viaXmlException. It is bounded — the 64-level depth cap means no single document can amplify it — but if you are validating untrusted JSON in a hot loop, cap the input size before you call, as you would anyway.
Architecture
src/SaddamHossain.Toolkit.Validation/
├── Validators/ Validator — one partial file per category, public API
├── Extensions/ One *ValidationExtensions class per category, public API
├── Helpers/ One internal type per validation algorithm
├── Internal/ Cross-cutting internals: input policy, character classes, set lookup
├── Constants/ Domain lists, file-extension lists, every limit with its citation
└── Models/ PasswordStrength
Three decisions shape everything else.
One implementation per rule. Extension methods are one-line forwarders to Validator methods —
never the other way round, and never a second copy of the logic. Two reflection tests enforce it:
every validator has a matching extension, every extension has a matching validator, and both call
styles return the same answer for the same input.
Algorithms are separated from policy. Each Helpers/*Syntax type knows one grammar and nothing
about the package's conventions; each Validator method applies the input policy and calls one
helper. That is what lets IsEmail, IsDisposableEmail and IsBusinessEmail share a single
address parser, and IsNationalId, IsPassport and IsTaxNumber share a single parameterised
identifier scanner, without any of the six duplicating a rule.
Limits are data, and they carry citations. Every bound the package enforces lives in
ValidationLimits with the RFC, ISO or ITU section it comes from. A limit with no citation is a
guess, and a guess in a validation library is a bug waiting for the input that trips it.
Two smaller choices worth knowing about:
SortedTextSetinstead ofFrozenSet. The obvious container for "is this domain on the list?" would be a hash set, and it would be right if the domain were already astring. It is not — it is a slice of the address the caller passed in, so every lookup would begin with aToString(). A binary search over a sorted array answers the same question in ~8 span comparisons with no allocation on any target framework.FrozenSetgained a span-based alternate lookup in .NET 9, but this package also ships for .NET 8, and a set that is fast on one framework and allocating on another is worse than one that is predictable on all three. The sortedness invariant is enforced by unit tests, since nothing can check it at run time without defeating the point.- No
InternalsVisibleToshortcuts in the public surface. Helpers are internal and stay internal; they are tested through the public API and, where they carry an invariant of their own, directly via friend-assembly access from the test and benchmark projects only.
Roadmap
Feature areas are added only where they earn their place. Public API changes follow Semantic Versioning — no breaking change without a major version bump.
Under consideration, in no committed order:
| Candidate | Notes |
|---|---|
ValidationOptions overloads |
Caller-supplied strictness — allowing single-label e-mail domains for intranet use, or a caller-supplied scheme allow-list for IsUrl. Additive, so a minor release. |
| Card network detection | GetCardNetwork() returning Visa / Mastercard / Amex from the issuer identification number. Distinct from IsCreditCard, which deliberately does not care. |
IsSemVer, IsIsbn, IsIban |
Each has a real checksum or grammar worth implementing properly. IBAN in particular has a mod-97 check that catches far more than a length rule. |
Punycode-aware IsEmail |
Encoding an internationalised domain before validating it, rather than asking the caller to. Needs System.Globalization.IdnMapping, which is trim-safe, so it is a real possibility. |
| A refreshed disposable-domain list | The list is a snapshot. A maintenance release that updates it is cheap and useful; a runtime download is not, and will not happen. |
Requests and use cases are welcome on the issue tracker — a validator that solves a real problem you have is a much better argument than one that rounds out a table.
Contributing
Contributions are welcome — a bug report naming an input that is classified wrongly is the single most valuable thing this project can receive.
Start with CONTRIBUTING.md, which covers building, the test matrix, the conventions, and what a new validator has to ship alongside its implementation. In short:
- Open an issue first for anything that changes public API. Design discussion happens before implementation, not during review. Documentation and test improvements can go straight to a pull request.
dotnet build -c Releasemust produce zero warnings — warnings are errors here — anddotnet format --verify-no-changesmust pass.- Tests cover the happy path, invalid values,
null, empty, white space, Unicode, boundary values and malformed input, namedMethodName_Should_ExpectedBehavior_When_State. - Every public member carries XML documentation with an
<example>, and — where the answer is easy to over-trust — a plain statement of what it does not mean.
Participation is governed by the Code of Conduct.
Security
Please report vulnerabilities privately through GitHub Security Advisories, not in a public issue. The policy — what counts as a vulnerability in a validation library, what is a documented limitation instead, and the supply-chain guarantees behind the package — is in SECURITY.md.
Two things worth knowing before you depend on this package:
- The package has zero NuGet dependencies, on every target framework, and CI asserts that
against the produced
.nuspecon every pull request. - Publishing uses NuGet Trusted Publishing (GitHub OIDC). No long-lived API key exists in this repository or on a maintainer's machine, and the workflow that can publish runs only on a published GitHub Release.
Versioning
This package follows Semantic Versioning 2.0.0.
| Change | Version bump | Example |
|---|---|---|
| Breaking change to the public API | Major — 2.0.0 |
A method removed or renamed, or its documented behaviour changed |
| New API, fully backward compatible | Minor — 1.1.0 |
A new validator, or a new overload |
| Bug fix with no API change | Patch — 1.0.1 |
A correctness fix, a performance improvement, a refreshed domain list |
Three guarantees come with that:
AssemblyVersionmoves only on a major release. Code compiled against1.0.0keeps loading1.4.2with no binding redirect. A unit test enforces this, so it cannot drift by accident.- The public API surface is pinned by a test. Any addition, removal or signature change fails the build with a readable diff, which makes every contract change a deliberate, reviewed decision rather than something that slips through.
- Every change is recorded in the CHANGELOG, with breaking changes under their own heading and a migration note.
One caveat specific to a validation library: tightening a validator is a breaking change even
though the signature does not move. If a future release stops accepting something 1.0.0 accepted,
it will say so under a Changed heading and it will not ship in a patch.
Pre-releases use the standard suffix form — 1.1.0-preview.1 — and are never promoted to stable
without a version bump.
License
Licensed under the MIT License — free for commercial and personal use, with no attribution required beyond retaining the notice. A copy of the licence ships inside the NuGet package itself.
Copyright © 2026 Md. Saddam Hossain
Links
| 🌐 Website | saddamhossain.net |
| 📦 NuGet | nuget.org/packages/SaddamHossain.Toolkit.Validation |
| 💻 Source | github.com/saddamhossain/SaddamHossain.Toolkit.Validation |
| 🐛 Issues | Report a bug or request a feature |
| 📋 Changelog | CHANGELOG.md |
| 🤝 Contributing | CONTRIBUTING.md |
| 🔒 Security | SECURITY.md |
| 🧰 Companion package | SaddamHossain.Toolkit.Extensions |
Built by Md. Saddam Hossain. If this package saves you time, a ⭐ on GitHub is appreciated.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 37 | 8/7/2026 |