SaddamHossain.Toolkit.Extensions
1.0.0
See the version list below for details.
dotnet add package SaddamHossain.Toolkit.Extensions --version 1.0.0
NuGet\Install-Package SaddamHossain.Toolkit.Extensions -Version 1.0.0
<PackageReference Include="SaddamHossain.Toolkit.Extensions" Version="1.0.0" />
<PackageVersion Include="SaddamHossain.Toolkit.Extensions" Version="1.0.0" />
<PackageReference Include="SaddamHossain.Toolkit.Extensions" />
paket add SaddamHossain.Toolkit.Extensions --version 1.0.0
#r "nuget: SaddamHossain.Toolkit.Extensions, 1.0.0"
#:package SaddamHossain.Toolkit.Extensions@1.0.0
#addin nuget:?package=SaddamHossain.Toolkit.Extensions&version=1.0.0
#tool nuget:?package=SaddamHossain.Toolkit.Extensions&version=1.0.0
SaddamHossain.Toolkit.Extensions
A lightweight, dependency-free collection of high-performance extension methods for modern .NET applications.
Status: in development. Version 1.0.0 has not been published to NuGet.org yet. The package infrastructure is complete and CI-verified; the extension methods listed under Roadmap are being implemented one feature at a time. This README documents the API as it lands — nothing below is illustrative or aspirational unless explicitly marked.
Introduction
Every .NET codebase accumulates the same small helper methods: slugify a string, truncate it safely, humanise a timestamp, format a byte count. They get copied between projects, drift apart, and are rarely tested at the edges.
SaddamHossain.Toolkit.Extensions is that layer, done once and done properly — argument
validation on every public entry point, allocation-conscious implementations, culture
correctness where it matters, and a test for every boundary case.
Features
- Zero dependencies. The package references no other NuGet package. Nothing is pulled into your dependency graph but this assembly.
- Multi-targeted for
net8.0,net9.0andnet10.0, using the best API available on each runtime rather than the lowest common denominator. - Trim- and AOT-safe. Marked
IsAotCompatible, so Native AOT and trimmed publishes stay warning-free. - Thread-safe. All extensions are pure functions over their inputs — no shared mutable state, no hidden side effects.
- Allocation-conscious.
Span<T>/ReadOnlySpan<T>where they genuinely help, ordinal string comparison by default, and no LINQ on hot paths. - Fully documented. Every public member ships XML documentation with parameters, return values, thrown exceptions and a worked example.
- Source Link + symbols. Step straight into the source from your debugger.
Installation
dotnet add package SaddamHossain.Toolkit.Extensions
Or via the Package Manager Console:
Install-Package SaddamHossain.Toolkit.Extensions
Quick Start
Every public extension lives in a single namespace, so one using makes the whole library
discoverable through IntelliSense:
using SaddamHossain.Toolkit.Extensions;
That is deliberate. The library is organised into folders internally, but splitting the
namespace by feature would force you to import five namespaces to reach one package —
the same reasoning behind System.Linq and Microsoft.Extensions.*.
Examples
Every example below is executed by the test suite and by the sample application, so none of them can drift from the implementation.
IsNullOrWhiteSpace()
Annotated with [NotNullWhen(false)], so the compiler narrows the type for you:
string? name = GetUserInput();
if (!name.IsNullOrWhiteSpace())
{
Console.WriteLine(name.Length); // No null warning, no `!` operator.
}
RemoveWhitespace()
" hello world ".RemoveWhitespace(); // "helloworld"
"1 234 567".RemoveWhitespace(); // "1234567"
"a b".RemoveWhitespace(); // "ab" — non-breaking space is whitespace too
"no-spaces".RemoveWhitespace(); // same instance returned; nothing allocated
Truncate()
The suffix is counted inside the budget, so the result never exceeds maxLength:
"Hello, World".Truncate(5); // "Hello"
"Hello, World".Truncate(8, "…"); // "Hello, …" — exactly 8 characters
"Hello, World".Truncate(9, "..."); // "Hello,..." — exactly 9 characters
"Hello".Truncate(20, "…"); // "Hello" — nothing removed, so no suffix
"ab\U0001F600".Truncate(3); // "ab" — never splits a surrogate pair
ToSlug()
"Hello, World!".ToSlug(); // "hello-world"
" Crème Brûlée ".ToSlug(); // "creme-brulee" — Latin diacritics folded
"C# 13 -- What's New?".ToSlug(); // "c-13-what-s-new"
"São Paulo".ToSlug(); // "sao-paulo"
"***".ToSlug(); // ""
ToSlug() uses the invariant culture, so "IDEA".ToSlug() is "idea" on every machine —
including Turkish locales, where a culture-sensitive lowercase would produce "ıdea".
Two limitations are deliberate and documented rather than silently handled:
"Straße".ToSlug(); // "stra-e" — ß has no Unicode decomposition to fold
"বাংলা".ToSlug(); // "" — output is ASCII-only
Both are pinned by tests, so they cannot change without a visible, versioned decision.
GetAge()
Counts completed anniversaries, not elapsed days divided by 365.25 — a day-count drifts by a day every leap year and reports the wrong age on roughly one birthday in four.
var birthDate = new DateTime(1990, 6, 15);
birthDate.GetAge(new DateTime(2026, 6, 14)); // 35 — day before the birthday
birthDate.GetAge(new DateTime(2026, 6, 15)); // 36 — on the birthday
birthDate.GetAge(); // measured against DateTime.Today
Leap-day birthdays are handled explicitly. The anniversary falls on 1 March in non-leap years:
var leapBirthDate = new DateTime(2000, 2, 29);
leapBirthDate.GetAge(new DateTime(2001, 2, 28)); // 0
leapBirthDate.GetAge(new DateTime(2001, 3, 1)); // 1
leapBirthDate.GetAge(new DateTime(2004, 2, 29)); // 4 — exact leap-day anniversary
leapBirthDate.GetAge(new DateTime(2100, 2, 28)); // 99 — 2100 is not a leap year
A birth date in the future throws ArgumentOutOfRangeException rather than returning a negative
number, so a data-entry error cannot propagate silently.
ToHumanTime()
var now = new DateTime(2026, 8, 5, 12, 0, 0, DateTimeKind.Utc);
now.AddSeconds(-30).ToHumanTime(now); // "just now"
now.AddMinutes(-1).ToHumanTime(now); // "1 minute ago"
now.AddHours(-3).ToHumanTime(now); // "3 hours ago"
now.AddDays(-10).ToHumanTime(now); // "1 week ago"
now.AddDays(-90).ToHumanTime(now); // "2 months ago"
now.AddMinutes(5).ToHumanTime(now); // "in 5 minutes"
The single-argument overload picks its reference clock from DateTimeKind — UtcNow for a UTC
value, Now otherwise. This prevents the most common bug in relative-time formatting: comparing
a UTC timestamp from a database against local wall-clock time and reporting something that
happened seconds ago as "6 hours ago".
DateTime.UtcNow.AddHours(-3).ToHumanTime(); // "3 hours ago" — correct
DateTime.Now.AddHours(-3).ToHumanTime(); // "3 hours ago" — also correct
ToHumanTime() never throws, and its output is English-only with invariant digits.
ToReadableFileSize()
Binary steps (1024) with the customary unit names, matching what Windows Explorer shows and what users expect when they read "1 KB". Trailing zeros are dropped.
0.ToReadableFileSize(); // "0 B" — allocates nothing
512.ToReadableFileSize(); // "512 B"
1024.ToReadableFileSize(); // "1 KB"
1536.ToReadableFileSize(); // "1.5 KB"
1048576.ToReadableFileSize(); // "1 MB"
1073741824.ToReadableFileSize(); // "1 GB"
1099511627776L.ToReadableFileSize(); // "1 TB"
long.MaxValue.ToReadableFileSize(); // "8 EB"
Negative values format rather than throw, since a size delta is a legitimate use:
(-1536L).ToReadableFileSize(); // "-1.5 KB"
long.MinValue.ToReadableFileSize(); // "-8 EB" — no overflow
Custom precision, 0 to 15 places:
1590L.ToReadableFileSize(0); // "2 KB"
1590L.ToReadableFileSize(3); // "1.553 KB"
A value that would round up to a whole unit is promoted, so you never see the technically-true
but jarring "1024 KB":
1048575L.ToReadableFileSize(); // "1 MB" — 1023.999… KB promoted
1048575L.ToReadableFileSize(3); // "1023.999 KB" — precision keeps it below the boundary
ToOrdinal()
1.ToOrdinal(); // "1st"
2.ToOrdinal(); // "2nd"
3.ToOrdinal(); // "3rd"
4.ToOrdinal(); // "4th"
11.ToOrdinal(); // "11th" — the teens exception
12.ToOrdinal(); // "12th"
13.ToOrdinal(); // "13th"
21.ToOrdinal(); // "21st"
22.ToOrdinal(); // "22nd"
23.ToOrdinal(); // "23rd"
111.ToOrdinal(); // "111th" — decided by the last TWO digits, not the last one
0.ToOrdinal(); // "0th"
The suffix is chosen from the last two digits, which is exactly where hand-rolled
implementations get 111 wrong. Never throws, including at int.MinValue.
IsNullOrEmpty()
One overload covers every sequence type. Anything exposing a Count is answered in O(1) with
zero allocations; only a genuinely lazy sequence is enumerated, and then exactly one element is
pulled:
List<int>? list = null;
list.IsNullOrEmpty(); // true
new List<int>().IsNullOrEmpty(); // true
new[] { 1, 2, 3 }.IsNullOrEmpty(); // false
new Dictionary<string, int>().IsNullOrEmpty(); // true
new HashSet<int>().IsNullOrEmpty(); // true
IEnumerable<int> query = Enumerable.Range(1, 10).Where(n => n > 100);
query.IsNullOrEmpty(); // true — one element pulled, no Count()
// Flow analysis: no null-forgiving operator needed.
if (!list.IsNullOrEmpty())
{
Console.WriteLine(list.Count);
}
Prefer
string.IsNullOrEmpty(s)for strings.stringis anIEnumerable<char>but implements neither collection interface, so it would take the enumeration path here.
IsEmpty()
Guid.Empty.IsEmpty(); // true
default(Guid).IsEmpty(); // true
Guid.NewGuid().IsEmpty(); // false
A 16-byte struct comparison the JIT inlines away entirely. No allocation, nothing to throw.
Requirements
| Runtime | .NET 8.0, .NET 9.0 or .NET 10.0 |
| SDK (to build) | .NET 10.0 SDK or later |
| Language | C# 12 or later |
| OS | Any platform supported by .NET — no OS-specific code |
Roadmap
1.0.0 — initial release
| Status | Method | Target type | Purpose |
|---|---|---|---|
| ✅ | ToSlug() |
string |
URL-safe slug, invariant culture |
| ✅ | RemoveWhitespace() |
string |
Strip all whitespace |
| ✅ | Truncate() |
string |
Length-limit, suffix counted within the budget |
| ✅ | IsNullOrWhiteSpace() |
string? |
Null/whitespace check with flow analysis |
| ✅ | GetAge() |
DateTime |
Completed anniversaries, leap-day correct |
| ✅ | ToHumanTime() |
DateTime |
Relative phrasing — "3 hours ago" |
| ✅ | IsNullOrEmpty() |
IEnumerable<T>? |
Null/empty check, O(1) for counted sequences |
| ✅ | ToReadableFileSize() |
long, int |
Byte count to "1.5 KB" |
| ✅ | ToOrdinal() |
int |
"1st", "2nd", "3rd" |
| ✅ | IsEmpty() |
Guid |
Compare against Guid.Empty |
All ten methods for 1.0.0 are implemented, with 310 tests running against every target framework.
IsNullOrWhiteSpace()was originally planned asIsNullOrWhiteSpaceEx(). TheExsuffix is reserved by the Framework Design Guidelines (CA1711), and there is no ambiguity to disambiguate:stringhas no instance member of that name, only the staticstring.IsNullOrWhiteSpace(x).
Beyond 1.0.0
Additional feature areas will be introduced only where they earn their place. Public API changes follow Semantic Versioning — no breaking change without a major version bump.
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, renamed, or its signature or documented behaviour changed |
| New API, fully backward compatible | Minor — 1.1.0 |
A new extension method or a new overload |
| Bug fix with no API change | Patch — 1.0.1 |
A correctness fix, a performance improvement |
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 listed under their own heading and a migration note.
Pre-releases use the standard suffix form — 1.1.0-preview.1 — and are never promoted to stable
without a version bump.
Links
| 🌐 Website | saddamhossain.net |
| 📦 NuGet | nuget.org/packages/SaddamHossain.Toolkit.Extensions |
| 💻 Source | github.com/saddamhossain/SaddamHossain.Toolkit.Extensions |
| 🐛 Issues | Report a bug or request a feature |
| 📋 Changelog | CHANGELOG.md |
Contributing
Contributions are welcome. Before opening a pull request:
- Open an issue describing the problem or the proposed API. Design discussion happens before implementation, not during review.
- Ensure
dotnet build -c Releaseproduces zero warnings — warnings are errors here. - Ensure
dotnet format --verify-no-changespasses. - Add tests covering the happy path, edge cases, null inputs, boundary values and invalid
arguments. Follow the
MethodName_Should_ExpectedBehavior_When_Stateconvention. - Add XML documentation to every public member, including an
<example>.
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
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.