SaddamHossain.Toolkit.Extensions
1.1.0
dotnet add package SaddamHossain.Toolkit.Extensions --version 1.1.0
NuGet\Install-Package SaddamHossain.Toolkit.Extensions -Version 1.1.0
<PackageReference Include="SaddamHossain.Toolkit.Extensions" Version="1.1.0" />
<PackageVersion Include="SaddamHossain.Toolkit.Extensions" Version="1.1.0" />
<PackageReference Include="SaddamHossain.Toolkit.Extensions" />
paket add SaddamHossain.Toolkit.Extensions --version 1.1.0
#r "nuget: SaddamHossain.Toolkit.Extensions, 1.1.0"
#:package SaddamHossain.Toolkit.Extensions@1.1.0
#addin nuget:?package=SaddamHossain.Toolkit.Extensions&version=1.1.0
#tool nuget:?package=SaddamHossain.Toolkit.Extensions&version=1.1.0
SaddamHossain.Toolkit.Extensions
A lightweight, dependency-free collection of high-performance extension methods for modern .NET applications.
Version 1.1.0 — 68 extension methods across
string,DateTime, numeric types,Guid, collections and sequences. Purely additive over 1.0.0: nothing was removed, renamed, or changed in behaviour. See the CHANGELOG for the two source-level notes for upgraders.
Introduction
Every .NET codebase accumulates the same small helper methods: slugify a string, truncate it safely, humanise a timestamp, format a byte count, mask a card number for display. They get copied between projects, drift apart, and are rarely tested at the edges — which is exactly where they break, on an emoji, a Turkish locale, a leap year, or a Bengali vowel sign.
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. Nounsafe, no reflection, nodynamic, no regular expressions. - Thread-safe. All extensions are pure functions over their inputs — no shared mutable state, no hidden side effects. Nothing mutates the collection you pass it.
- Allocation-conscious.
Span<T>/ReadOnlySpan<T>where they genuinely help,stackallocbelow 256 characters andArrayPool<char>above it, ordinal string comparison by default, and no LINQ on hot paths. Methods that can return their input unchanged do, allocating nothing. - Unicode-correct, not Unicode-hopeful. Reversal is by grapheme cluster, truncation never
splits a surrogate pair, palindrome tests compare
Runes, and combining marks stay attached to their letters so Indic and Arabic scripts survive intact. - Deterministic by default. Casing, slugs and formatting use the invariant culture, so the same code gives the same answer on a Turkish machine. Where a culture or a week-start genuinely matters, it is an explicit parameter rather than an ambient read.
- 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.*.
API
68 methods. Everything marked new arrived in 1.1.0.
string — text
| Method | Returns | Summary |
|---|---|---|
IsNullOrWhiteSpace() |
bool |
Null/whitespace check with flow analysis |
IsNullOrEmpty() new |
bool |
Null/empty check with flow analysis |
RemoveWhitespace() |
string |
Delete all white space |
NormalizeWhitespace() new |
string |
Collapse runs to one space, trim ends |
RemoveSpecialCharacters() new |
string |
Keep letters, digits and combining marks |
Truncate(int) / (int, string) |
string |
Length-limit, suffix counted within the budget |
Left(int) new |
string |
Leftmost characters, no Math.Min guard needed |
Right(int) new |
string |
Rightmost characters |
Reverse() new |
string |
Reverse by grapheme cluster |
ToSlug() |
string |
URL-safe slug, invariant culture |
string — casing
| Method | Returns | Summary |
|---|---|---|
Capitalize() new |
string |
Uppercase the first character only |
ToTitleCase() new |
string |
Title case, invariant culture |
ToCamelCase() new |
string |
helloWorld |
ToPascalCase() new |
string |
HelloWorld |
ToSnakeCase() new |
string |
hello_world |
ToKebabCase() new |
string |
hello-world |
string — search and masking
| Method | Returns | Summary |
|---|---|---|
IsPalindrome() new |
bool |
Ignores case, punctuation and spacing |
ContainsIgnoreCase(string) new |
bool |
Ordinal, case-insensitive |
EqualsIgnoreCase(string?) new |
bool |
Ordinal, case-insensitive, null-safe both sides |
Mask() / (int, int) / (int, int, char) new |
string |
Redact, optionally keeping a window |
MaskEmail() new |
string |
j******e@example.com |
MaskPhone() new |
string |
+* (***) ***-4567 |
MaskCreditCard() new |
string |
**** **** **** 1111 |
DateTime
| Method | Returns | Summary |
|---|---|---|
GetAge() / (DateTime) |
int |
Completed anniversaries, leap-day correct |
ToHumanTime() / (DateTime) |
string |
Relative phrasing — "3 hours ago" |
StartOfDay() new |
DateTime |
Midnight |
EndOfDay() new |
DateTime |
23:59:59.9999999 |
StartOfWeek() / (DayOfWeek) new |
DateTime |
ISO 8601 Monday by default |
EndOfWeek() / (DayOfWeek) new |
DateTime |
Last tick of the week |
StartOfMonth() new |
DateTime |
First of the month |
EndOfMonth() new |
DateTime |
Last tick, leap-year correct |
IsWeekend() / IsWeekday() new |
bool |
Saturday/Sunday convention |
Every boundary preserves DateTimeKind.
Numbers and Guid
| Method | Returns | Summary |
|---|---|---|
ToReadableFileSize() / (int) |
string |
Byte count to "1.5 KB" |
ToOrdinal() |
string |
"1st", "2nd", "3rd" |
Clamp<T>(T, T) new |
T |
Constrain to an inclusive range |
InRange<T>(T, T) new |
bool |
Inclusive range test, evaluates the receiver once |
IsEven<T>() / IsOdd<T>() new |
bool |
Correct for negatives; not mutual negations |
ToPercentage<T>(T) / (T, int) new |
double |
Part of a whole, never floored |
Guid.IsEmpty() |
bool |
Compare against Guid.Empty |
Guid.IsNotEmpty() new |
bool |
The positive form |
The numeric methods are generic over INumber<T>, so they cover int, long, double,
decimal, BigInteger and any custom numeric type from one implementation.
Collections and sequences
| Method | Receiver | Returns | Summary |
|---|---|---|---|
IsNullOrEmpty() |
IEnumerable<T>? |
bool |
O(1) for counted sequences |
IsNotEmpty() new |
IEnumerable<T>? |
bool |
The positive form |
None(predicate) new |
IEnumerable<T> |
bool |
Complement of Any(predicate) |
HasDuplicates() / (comparer) new |
IEnumerable<T> |
bool |
One pass, exits at the first repeat |
ForEach(action) / (action, index) new |
IEnumerable<T> |
void |
Immediate, not fluent |
DistinctBy(...) new |
IReadOnlyList<T> |
T[] |
Eager, exactly sized |
Chunk(int) new |
IReadOnlyList<T> |
T[][] |
Eager, exactly sized |
Shuffle() / (Random) new |
IReadOnlyList<T> |
T[] |
Unbiased, never mutates the source |
Batch(int) new |
IEnumerable<T> |
IEnumerable<T[]> |
Lazy, streaming counterpart to Chunk |
Page(int, int) new |
IEnumerable<T> |
IEnumerable<T> |
One-based paging |
PageBy(key, int, int) new |
IEnumerable<T> |
IEnumerable<T> |
Orders first, so paging is deterministic |
Why
Chunk,DistinctByandShuffletakeIReadOnlyList<T>. The BCL already has all three onIEnumerable<T>(Shufflefrom .NET 10). Declaring ours onIEnumerable<T>too would make every call site with both namespaces in scope fail to compile withCS0121: ambiguous call. Binding toIReadOnlyList<T>is more specific, so a list or array reaches these methods and a bare sequence reaches the framework's — and knowing the count up front is what lets these allocate each buffer once at exactly the right size. UseBatchwhen your source is lazy.
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() / IsNotEmpty()
Guid.Empty.IsEmpty(); // true
default(Guid).IsEmpty(); // true
Guid.NewGuid().IsEmpty(); // false
Guid.NewGuid().IsNotEmpty(); // true
A 16-byte struct comparison the JIT inlines away entirely. No allocation, nothing to throw.
NormalizeWhitespace() / RemoveSpecialCharacters()
" hello world ".NormalizeWhitespace(); // "hello world"
"a\t\tb\n\nc".NormalizeWhitespace(); // "a b c"
"already normal".NormalizeWhitespace(); // same instance, nothing allocated
The character is normalised, not just the run — a non-breaking or ideographic space becomes a plain U+0020, so text pasted from a word processor stops comparing unequal to text that looks identical:
"a b".NormalizeWhitespace(); // "a b" — U+00A0 becomes U+0020
"a b".NormalizeWhitespace(); // "a b" — ideographic space too
RemoveSpecialCharacters() keeps letters, digits and combining marks:
"Hello, World!".RemoveSpecialCharacters(); // "HelloWorld" — spaces go too
"+1 (555) 123-4567".RemoveSpecialCharacters(); // "15551234567"
"\"বাংলা\"!".RemoveSpecialCharacters(); // "বাংলা"
That last one is the reason marks are retained. In Bengali, Devanagari, Thai and Arabic a vowel is
a mark attached to a consonant, so a letters-and-digits-only filter returns "বল" — the word with
its vowels deleted out of the middle. Astral characters and emoji are removed in full, never split.
Left() / Right()
Asking for more than there is returns everything, rather than throwing the way Substring does —
which is the Math.Min guard you would otherwise write at every call site:
"Hello, World".Left(5); // "Hello"
"Hello, World".Right(5); // "World"
"Hello".Left(20); // "Hello" — same instance, no exception
"Hello".Left(0); // ""
Both are surrogate-safe, and both fail toward taking less:
"ab\U0001F600".Left(3); // "ab" — never half an emoji
"\U0001F600ab".Right(3); // "ab"
Reverse()
Reversal is by grapheme cluster, not by char. The obvious code-unit reversal splits every
surrogate pair and detaches every combining mark:
"Hello".Reverse(); // "olleH"
"ab\U0001F600".Reverse(); // "\U0001F600ba" — emoji kept whole
"café".Reverse(); // "éfac" — the accent stays on its own letter
Reversing twice returns the original, which a char-based implementation cannot promise. ASCII
input takes a vectorised fast path, so correctness costs nothing in the common case.
Casing
"hello".Capitalize(); // "Hello"
"hello world".Capitalize(); // "Hello world" — first character only
"hELLO".Capitalize(); // "HELLO" — the remainder is preserved
"hello world".ToTitleCase(); // "Hello World"
"NASA launch".ToTitleCase(); // "NASA Launch" — all-caps words kept as acronyms
"HELLO WORLD".ToTitleCase(); // "HELLO WORLD" — for shouty text, lowercase it first
The four naming conventions share one word-splitter, so they cannot disagree about where a word divides:
"XMLHttpRequest".ToCamelCase(); // "xmlHttpRequest"
"XMLHttpRequest".ToPascalCase(); // "XmlHttpRequest"
"XMLHttpRequest".ToSnakeCase(); // "xml_http_request"
"XMLHttpRequest".ToKebabCase(); // "xml-http-request"
"hello world".ToSnakeCase(); // "hello_world"
"user2Name".ToSnakeCase(); // "user2_name" — digits stay with their word
"বাংলা টেক্সট".ToSnakeCase(); // "বাংলা_টেক্সট" — vowel signs are not separators
Casing inside a word is normalised rather than preserved, which is what makes the four mutually invertible:
"XMLHttpRequest".ToSnakeCase().ToPascalCase(); // "XmlHttpRequest"
All of it is invariant, so "ID".ToSnakeCase() is "id" on a Turkish machine and not "ıd".
IsPalindrome() / ContainsIgnoreCase() / EqualsIgnoreCase()
"racecar".IsPalindrome(); // true
"RaceCar".IsPalindrome(); // true — case ignored
"A man, a plan, a canal: Panama".IsPalindrome(); // true — punctuation ignored
"hello".IsPalindrome(); // false
Zero allocation: nothing is filtered into a temporary string and nothing is reversed. The
two-pointer walk compares Runes, so astral characters are not seen reversed relative to each
other, and it returns at the first mismatch.
"Hello, World".ContainsIgnoreCase("WORLD"); // true
"Hello".EqualsIgnoreCase("HELLO"); // true
string? missing = null;
missing.EqualsIgnoreCase(null); // true — never throws
missing.EqualsIgnoreCase("hello"); // false
EqualsIgnoreCase is null-safe on both sides because equality is total; ContainsIgnoreCase
requires a non-null receiver because containment has no meaningful answer for a missing operand.
Both are ordinal, so "straße" does not equal "STRASSE".
Masking
"secret".Mask(); // "******"
"1234567890".Mask(2, 2); // "12******90"
"1234567890".Mask(2, 2, '#'); // "12######90"
"john.doe@example.com".MaskEmail(); // "j******e@example.com"
"+1 (555) 123-4567".MaskPhone(); // "+* (***) ***-4567"
"4111 1111 1111 1111".MaskCreditCard(); // "**** **** **** 1111"
Digits are counted, not indexed, so formatting does not change the result — the same card redacts identically whether or not it is grouped:
"4111111111111111".MaskCreditCard(); // "************1111"
"4111-1111-1111-1111".MaskCreditCard(); // "****-****-****-1111"
Everything here fails closed. A window that would reveal the whole value, an address with no local part, or a number with four digits or fewer is masked completely rather than passed through:
"1234".Mask(2, 2); // "****" — not "1234"
"123".MaskPhone(); // "***"
"not-an-address".MaskEmail(); // "**************"
These are display helpers. They do not make the original value safe to store, log or transmit.
Date and time boundaries
var value = new DateTime(2026, 8, 6, 14, 30, 45, DateTimeKind.Utc); // a Thursday
value.StartOfDay(); // 2026-08-06 00:00:00.0000000 (Kind = Utc)
value.EndOfDay(); // 2026-08-06 23:59:59.9999999 (Kind = Utc)
value.StartOfWeek(); // 2026-08-03 — ISO 8601 Monday
value.EndOfWeek(); // 2026-08-09 23:59:59.9999999
value.StartOfMonth(); // 2026-08-01
value.EndOfMonth(); // 2026-08-31 23:59:59.9999999
value.IsWeekend(); // false
Three decisions worth knowing about:
DateTimeKindis preserved. The obvious implementation loses it —new DateTime(y, m, d)silently producesUnspecified— and the loss surfaces much later as an offset-sized error.End*returns the last tick, not the last second.23:59:59silently drops every timestamp in the final second of the day. A half-open range is still the safer shape against a database whose resolution is coarser than 100 ns.- Weeks start on Monday, not on
CurrentCulture. The same query returns the same seven days on every machine. Opting in to the locale is possible, and visible:
value.StartOfWeek(DayOfWeek.Sunday); // 2026-08-02
value.StartOfWeek(CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek);
EndOfMonth() is leap-year correct and does not overflow at the top of the range:
new DateTime(2024, 2, 10).EndOfMonth(); // 2024-02-29 — leap year
new DateTime(2100, 2, 10).EndOfMonth(); // 2100-02-28 — divisible by 100, not 400
new DateTime(9999, 12, 15).EndOfMonth(); // DateTime.MaxValue, no exception
Clamp() / InRange() / IsEven() / IsOdd() / ToPercentage()
One generic-math implementation each, covering every numeric type:
15.Clamp(1, 10); // 10
2.5.Clamp(0.0, 1.0); // 1.0
255L.Clamp(0L, 200L); // 200
5.InRange(1, 10); // true
1.InRange(1, 10); // true — both bounds are inclusive
11.InRange(1, 10); // false
IsEven and IsOdd are correct for negatives, where the usual value % 2 == 1 test is not — C#
gives the remainder the sign of the dividend:
(-3).IsOdd(); // true
(-3) % 2 == 1; // false — the bug this replaces
0.IsEven(); // true
2.5.IsEven(); // false — and 2.5.IsOdd() is false too, so they are not negations
ToPercentage is the part-of-whole form, and returns double so integer inputs are not floored:
25.ToPercentage(200); // 12.5
1.ToPercentage(3); // 33.333333333333336 — not 33
1.ToPercentage(3, 2); // 33.33
125.ToPercentage(1000, 0); // 13 — away from zero, not banker's rounding
An inverted range throws rather than being silently swapped, and a zero total throws rather than returning a quiet infinity.
Collections
int[] numbers = [1, 3, 5, 7];
numbers.IsNotEmpty(); // true
numbers.None(n => n % 2 == 0); // true — reads better than !Any(...)
numbers.HasDuplicates(); // false
new[] { 1, 2, 2 }.HasDuplicates(); // true
HasDuplicates makes one pass and returns at the first repeat, replacing
source.Count() != source.Distinct().Count() — which enumerates twice, always reads everything,
and breaks outright on a single-pass sequence.
int[] source = [1, 2, 3, 4, 5];
source.Chunk(2); // [1,2] [3,4] [5] — T[][], eager and exactly sized
source.Shuffle(); // a new array; `source` is untouched
source.DistinctBy(n => n % 3); // first element per key, input order preserved
Shuffle uses an unbiased Fisher–Yates rather than the widespread
OrderBy(_ => Guid.NewGuid()) idiom, which is both biased and far slower. Passing the generator
makes a shuffle reproducible, and therefore testable:
source.Shuffle(new Random(Seed: 42)); // the same ordering every run
Streaming and paging
Batch is the lazy counterpart to Chunk — one batch in memory at a time, source read once, so
it works on a sequence far larger than memory:
foreach (Order[] batch in database.StreamOrders().Batch(500))
{
await bulkWriter.WriteAsync(batch); // 500 at a time, never all of them
}
Each batch is a fresh array and safe to retain; the buffer is deliberately not reused, which is the classic source of "the last batch appeared several times" bugs. The final batch is trimmed to its real length, never padded.
int[] numbers = [1, 2, 3, 4, 5, 6, 7];
numbers.Page(1, 3); // [1, 2, 3] — pages count from one
numbers.Page(3, 3); // [7]
numbers.Page(99, 3); // [] — past the end is empty, not an exception
PageBy makes the sort key a required argument, because paging an unordered sequence is how one
record ends up on two pages while another is never shown at all — a bug that passes every test
against a small fixture:
people.PageBy(p => p.Name, pageNumber: 1, pageSize: 2); // ordered, then paged
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 |
Release notes
1.1.0 — 6 August 2026
52 new methods, taking the public surface from 16 to 68. Purely additive: nothing that shipped
in 1.0.0 was removed, renamed, or changed in behaviour, and AssemblyVersion stays at 1.0.0.0,
so an assembly compiled against 1.0.0 keeps binding to this one with no redirect.
| Area | Added |
|---|---|
string — text |
IsNullOrEmpty, NormalizeWhitespace, RemoveSpecialCharacters, Left, Right, Reverse |
string — casing |
Capitalize, ToTitleCase, ToCamelCase, ToPascalCase, ToSnakeCase, ToKebabCase |
string — search |
IsPalindrome, ContainsIgnoreCase, EqualsIgnoreCase |
string — masking |
Mask (×3), MaskEmail, MaskPhone, MaskCreditCard |
DateTime |
StartOfDay, EndOfDay, StartOfWeek (×2), EndOfWeek (×2), StartOfMonth, EndOfMonth, IsWeekend, IsWeekday |
| Numbers | Clamp, InRange, IsEven, IsOdd, ToPercentage (×2) |
Guid |
IsNotEmpty |
| Collections | IsNotEmpty, None, HasDuplicates (×2), ForEach (×2), DistinctBy (×2), Chunk, Shuffle (×2) |
| Sequences | Batch, Page, PageBy |
770 tests, up from 310, running against each of net8.0, net9.0 and net10.0 — 2,310
executions per run. PackageValidationBaselineVersion now diffs every build against the published
1.0.0 package, so a breaking change fails this build rather than a consumer's.
Two notes for upgraders, both source-level rather than binary:
text.IsNullOrEmpty()on astringnow binds to the newstringoverload instead ofCollectionExtensions.IsNullOrEmpty<char>. The answer is unchanged; it is simply no longer routed through an enumerator. Nothing to do.text.Reverse()on astringnow returnsstringrather thanIEnumerable<char>, because the new overload is more specific thanEnumerable.Reverse. Code written asnew string(text.Reverse().ToArray())will no longer compile — delete the wrapper, or calltext.AsEnumerable().Reverse()to keep the old binding.
The full list, with the reasoning behind each design decision, is in the CHANGELOG.
1.0.0 — 5 August 2026
The initial release: ToSlug, RemoveWhitespace, Truncate, IsNullOrWhiteSpace, GetAge,
ToHumanTime, IsNullOrEmpty, ToReadableFileSize, ToOrdinal and IsEmpty — ten methods, 310
tests.
Roadmap
Feature areas are introduced 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 |
|---|---|
DateOnly / TimeOnly / DateTimeOffset boundaries |
The same StartOf*/EndOf* shapes for the newer date types. DateTimeOffset in particular has no offset-preserving equivalent today. |
ToSlug(SlugOptions) |
Unicode-preserving slugs, and a caller-supplied transliteration table for ß, ø, æ, œ, đ, ł, þ — correct handling is language-dependent, which is why it cannot be a silent default. |
Localised ToHumanTime |
Needs a resource-based design; the current output is English-only by deliberate choice. |
TryParse-style conversion helpers |
Only if they can beat what the BCL already offers. |
Requests and use cases are welcome on the issue tracker — a method that solves a real problem you have is a much better argument than one that rounds out a table.
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.