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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SaddamHossain.Toolkit.Extensions" Version="1.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SaddamHossain.Toolkit.Extensions" Version="1.1.0" />
                    
Directory.Packages.props
<PackageReference Include="SaddamHossain.Toolkit.Extensions" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SaddamHossain.Toolkit.Extensions --version 1.1.0
                    
#r "nuget: SaddamHossain.Toolkit.Extensions, 1.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SaddamHossain.Toolkit.Extensions@1.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SaddamHossain.Toolkit.Extensions&version=1.1.0
                    
Install as a Cake Addin
#tool nuget:?package=SaddamHossain.Toolkit.Extensions&version=1.1.0
                    
Install as a Cake Tool

SaddamHossain.Toolkit.Extensions

NuGet Downloads Build License: MIT .NET

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.0 and net10.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. No unsafe, no reflection, no dynamic, 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, stackalloc below 256 characters and ArrayPool<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, DistinctBy and Shuffle take IReadOnlyList<T>. The BCL already has all three on IEnumerable<T> (Shuffle from .NET 10). Declaring ours on IEnumerable<T> too would make every call site with both namespaces in scope fail to compile with CS0121: ambiguous call. Binding to IReadOnlyList<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. Use Batch when 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 DateTimeKindUtcNow 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. string is an IEnumerable<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:

  • DateTimeKind is preserved. The obvious implementation loses it — new DateTime(y, m, d) silently produces Unspecified — and the loss surfaces much later as an offset-sized error.
  • End* returns the last tick, not the last second. 23:59:59 silently 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 a string now binds to the new string overload instead of CollectionExtensions.IsNullOrEmpty<char>. The answer is unchanged; it is simply no longer routed through an enumerator. Nothing to do.
  • text.Reverse() on a string now returns string rather than IEnumerable<char>, because the new overload is more specific than Enumerable.Reverse. Code written as new string(text.Reverse().ToArray()) will no longer compile — delete the wrapper, or call text.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 Major2.0.0 A method removed, renamed, or its signature or documented behaviour changed
New API, fully backward compatible Minor1.1.0 A new extension method or a new overload
Bug fix with no API change Patch1.0.1 A correctness fix, a performance improvement

Three guarantees come with that:

  • AssemblyVersion moves only on a major release. Code compiled against 1.0.0 keeps loading 1.4.2 with 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.

🌐 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:

  1. Open an issue describing the problem or the proposed API. Design discussion happens before implementation, not during review.
  2. Ensure dotnet build -c Release produces zero warnings — warnings are errors here.
  3. Ensure dotnet format --verify-no-changes passes.
  4. Add tests covering the happy path, edge cases, null inputs, boundary values and invalid arguments. Follow the MethodName_Should_ExpectedBehavior_When_State convention.
  5. 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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.1.0 47 8/6/2026
1.0.0 54 8/6/2026