Conventions
Every method in the library follows the same small set of rules. Read this page once and you can predict how a
method you have never used treats null, culture, time and bad arguments. Come back to it when a call does not
compile, or binds to a different method than you expected.
At a glance
| Method | What it does |
|---|---|
Window, Age, JoinWith | Invalid arguments throw ArgumentNullException / ArgumentOutOfRangeException with the parameter name |
Window, Scan, WithIndex, InRandomOrder | Lazy sequences check their arguments when you call them, not when you enumerate |
ContainsIgnoreCase, StartsWithIgnoreCase, EqualsIgnoreCase | String predicates return false for a null string |
ToKebabCase, SplitWords, FirstCharToUpper | The receiver's nullable annotation tells you whether null is allowed |
EndsWithIgnoreCase, ContainsAny, GetPropertyValue | Comparisons are ordinal unless you pass a StringComparison |
ToQueryString, JoinWith, ToFileSize, TryParse<T> | Formatting and parsing use the invariant culture |
ToCamelCase, FormatWith, ToTable | The few places where the current culture is used, or can be chosen |
ToRelativeTime, Age, After, Before | Nothing reads the system clock; you pass "now" |
GetPropertyValue, SetPropertyValue, GetAttribute<T> | Extensions live in the namespace of the type they extend |
WithIndex, Frequencies, InRandomOrder, Shuffle | Names avoid what .NET ships, such as Index, CountBy and Shuffle |
Scan, As<T>, DistinctUntilChanged, IsEmpty | Names shared with System.Interactive and FluentAssertions, and how to choose one |
ToFileSize, ToWords, IsEven | Receivers are not widened, so number helpers have int and long overloads |
FromUnixTimeSeconds | A long-only receiver needs an L suffix or a cast on an int |
Argument validation
Exceptions name the parameter
A null argument throws ArgumentNullException. A number outside the allowed range throws
ArgumentOutOfRangeException. In both cases ParamName is the parameter's name in the method signature, so the
message points at the argument you got wrong.
decimal[] prices = { 10m, 12m, 11m, 15m };
DateTime birthDate = new DateTime(2000, 6, 15);
string separator = null!;
prices.Window(0); // → ArgumentOutOfRangeException, ParamName "size"
birthDate.Age(new DateTime(2000, 6, 14)); // → ArgumentOutOfRangeException, ParamName "today"
prices.JoinWith(separator); // → ArgumentNullException, ParamName "separator"
An argument of the right type but with the wrong content throws ArgumentException. For example,
account.GetPropertyValue("Missing") throws ArgumentException with ParamName "propertyName". Each method's XML
docs list the exceptions it can throw.
Lazy sequences validate at call time
Methods that return a lazy IEnumerable<T> (Window, Scan, WithIndex, InRandomOrder, Iterate and the
others) check their arguments as soon as you call them. Only the iteration is deferred. A bad argument fails on the
line that passed it, not later inside a foreach in some other method.
decimal[] prices = { 10m, 12m, 11m, 15m };
IEnumerable<decimal> missing = null!;
IEnumerable<IReadOnlyList<decimal>> windows = prices.Window(0); // → ArgumentOutOfRangeException on this line
IEnumerable<decimal> totals = missing.Scan((sum, p) => sum + p); // → ArgumentNullException, ParamName "source"
When the arguments are valid, nothing runs until you enumerate:
decimal[] prices = { 10m, 12m, 11m, 15m };
IEnumerable<decimal> runningTotals = prices.Scan((sum, p) => sum + p); // nothing computed yet
List<decimal> totals = runningTotals.ToList(); // → [10, 22, 33, 48]
Null receivers
String predicates return false
A string predicate called on a null string returns false instead of throwing, so you can test optional input
without a null check first. The value you search for is different: passing null there is a bug in the calling
code, so it throws ArgumentNullException, even when the string is also null.
string? coupon = null;
bool hasFree = coupon.ContainsIgnoreCase("free"); // → false
bool isPromo = coupon.StartsWithIgnoreCase("PROMO-"); // → false
bool sameAsCode = coupon.EqualsIgnoreCase("SPRING24"); // → false
bool sameAsNull = coupon.EqualsIgnoreCase(null); // → true
"Hello".ContainsIgnoreCase((string)null!); // → ArgumentNullException, ParamName "toCheck"
coupon.ContainsIgnoreCase((string)null!); // → ArgumentNullException
EqualsIgnoreCase treats two null strings as equal, like
string.Equals(null, null, StringComparison.OrdinalIgnoreCase). In 1.x it returned false. See
Upgrading from 1.x.
Read the nullable annotations
Every public signature carries nullable annotations, and they are the contract. A receiver typed string? accepts
null. A receiver typed string throws ArgumentNullException for null. The casing methods accept null and
return null. Methods that must return a non-null result, such as SplitWords and FirstCharToUpper, reject it.
string? nickname = null;
string? slug = nickname.ToKebabCase(); // → null
string[] words = nickname!.SplitWords(); // → ArgumentNullException
string first = nickname!.FirstCharToUpper(); // → ArgumentNullException
With <Nullable>enable</Nullable> the compiler warns you before you pass a possibly-null value to a receiver typed
string. The ! operators above silence that warning only to show the exception.
Culture and comparison
Ordinal comparisons
String comparisons are ordinal: StringComparison.Ordinal, or StringComparison.OrdinalIgnoreCase for the
...IgnoreCase methods. The result does not depend on the machine's culture; Turkish casing rules, for example,
cannot make "FILE" and "file" differ. When you need another comparison, use the overload that takes a
StringComparison.
string fileName = "Report.PDF";
bool isDocument = fileName.EndsWithIgnoreCase(".pdf", ".docx"); // → true
bool exact = fileName.ContainsAny(StringComparison.Ordinal, ".pdf"); // → false
bool loose = fileName.ContainsAny(StringComparison.OrdinalIgnoreCase, ".pdf"); // → true
Member names are matched ordinally too, so they are case-sensitive:
using System.Reflection;
Account account = new Account { Owner = "Ahmad" };
object? owner = account.GetPropertyValue("Owner"); // → "Ahmad"
account.GetPropertyValue("owner"); // → ArgumentException, ParamName "propertyName"
public class Account
{
public string? Owner { get; set; }
}
Invariant formatting and parsing
Text that other programs read (query strings, CSV, joined values, parsed numbers, file sizes) uses
CultureInfo.InvariantCulture. A server set to German still writes 1.5, not 1,5, and parsing never guesses
whether a comma is a decimal separator.
using System.Globalization;
using IDisposable german = CultureInfo.GetCultureInfo("de-DE").Use();
Dictionary<string, object?> filter = new Dictionary<string, object?>
{
["price"] = 1.5m,
["ratio"] = 0.25,
["count"] = 1000,
};
string query = filter.ToQueryString(); // → "price=1.5&ratio=0.25&count=1000"
string weights = new[] { 1.5, 2.25 }.JoinWith(";"); // → "1.5;2.25"
string size = 1536L.ToFileSize(); // → "1.5 KB"
double? parsed = "1.5".TryParse<double>(); // → 1.5
double? comma = "1,5".TryParse<double>(); // → null
CultureInfo.Use() is the library's helper that switches the current culture until the handle is disposed. Here it
only shows that the results do not depend on the current culture.
ToQueryString writes IFormattable values (numbers, dates, ...) with the invariant culture. A custom type that does
not implement IFormattable is written with its own ToString(), which may use the current culture.
Opting in to the current culture
Some methods produce text for people rather than for programs. They either let you choose the culture or use the current one:
- The casing methods (
ToCamelCase,ToPascalCase,ToKebabCase,ToSnakeCase,ToTitleCase, ...) use invariant casing rules by default. PassuseCurrentCulture: trueto use the current culture's rules. FormatWith(source, provider)uses the current culture whenproviderisnull, which is the default.ToTable,ToMarkdownTableandToHtmlTableformat cell values with the current culture, because a person reads them on a console, in a log or on a page.
using System.Globalization;
using IDisposable turkish = CultureInfo.GetCultureInfo("tr-TR").Use();
string? invariant = "ID".ToCamelCase(); // → "id"
string? local = "ID".ToCamelCase(useCurrentCulture: true); // → "ıd"
using System.Globalization;
using IDisposable german = CultureInfo.GetCultureInfo("de-DE").Use();
string table = new[] { 1.5 }.ToTable(); // the value cell reads "1,5"
No system clock
Pass "now" in
No method reads DateTime.Now, DateTime.UtcNow or DateTimeOffset.Now. A method that needs the current time takes
it as a parameter. Your code decides which clock to use, and a test can pass a fixed moment and get the same answer on
every run.
DateTime now = new DateTime(2024, 5, 15, 12, 0, 0, DateTimeKind.Utc);
DateTime postedAt = new DateTime(2024, 5, 15, 11, 55, 0, DateTimeKind.Utc);
string posted = postedAt.ToRelativeTime(now); // → "5 minutes ago"
string due = now.AddDays(3).ToRelativeTime(now); // → "in 3 days"
DateTime birthDate = new DateTime(2000, 6, 15);
int age = birthDate.Age(new DateTime(2024, 6, 14)); // → 23
DateTime remindAt = 30.Minutes().After(now); // → 2024-05-15 12:30:00 (UTC)
DateTime cutoff = 3.Days().Before(now); // → 2024-05-12 12:00:00 (UTC)
In production, pass DateTime.UtcNow or the value from your clock abstraction, such as
timeProvider.GetUtcNow().UtcDateTime with .NET 8's TimeProvider. Give ToRelativeTime a reference with the same
DateTimeKind as the value; mixing local and UTC times shifts the result by your UTC offset.
See Dates and Durations for the rest of the time helpers.
Namespaces
Extensions live in BCL namespaces
Each extension class is declared in the namespace of the type it extends: System for strings, numbers, dates and
objects, System.Collections.Generic for sequences, lists and dictionaries, System.Threading.Tasks for tasks, and
so on. Those namespaces are already imported in almost every file (the SDK's implicit usings include most of them),
so after you install the package the methods show up in IntelliSense without a new using.
| Namespace | Classes |
|---|---|
System | StringExtensions, NumberExtensions, DateTimeExtensions, TimeSpanExtensions, ObjectExtensions, TypeExtensions, EnumExtensions, GuidExtensions, RandomExtensions, ExceptionExtensions, FunctionExtensions, ... |
System.Collections.Generic | EnumerableExtensions, ListExtensions, DictionaryExtensions, Page<T>, CollectionDiff<T>, ... |
System.Linq | QueryableExtensions, GroupingExtensions |
System.Threading.Tasks | AsyncExtensions, AwaitableExtensions, Retry, TaskTupleExtensions |
System.Threading | SemaphoreSlimExtensions |
System.IO | StreamExtensions, TextReaderExtensions |
System.Diagnostics | ProcessExtensions |
System.Globalization | CultureInfoExtensions |
System.Collections.Specialized | NameValueCollectionExtensions |
System.Reflection | ObjectExtensions (GetPropertyValue, SetPropertyValue), MemberInfoExtensions (GetAttribute<T>, GetAttributes<T>, HasAttribute<T>) |
The System.Reflection members are the ones you are most likely to look for and not find. They extend object and
MemberInfo, but their namespace is not imported by default:
using System.Reflection;
Account account = new Account { Owner = "Ahmad" };
object? owner = account.GetPropertyValue("Owner"); // → "Ahmad"
account.SetPropertyValue("Lina", "Owner"); // value first, then the property name
string? newOwner = account.Owner; // → "Lina"
Two classes are named ObjectExtensions: System.ObjectExtensions and System.Reflection.ObjectExtensions.
Extension-method calls are not affected. If you call one statically in a file that imports both namespaces, include
the namespace: System.ObjectExtensions.As<Order>(entity).
Name clashes
Names that .NET already uses
Method names avoid what LINQ and the BCL ship on .NET 8 and .NET 10, such as Chunk, DistinctBy, Index,
CountBy, Shuffle and LeftJoin. Where the library offers something similar, it uses a different name, so your
code keeps its meaning when you move to a newer runtime.
| .NET method | Library method | Difference |
|---|---|---|
Enumerable.Index() (.NET 9) | WithIndex() | yields (Item, Index); also works on the netstandard2.1 build |
Enumerable.CountBy() (.NET 9) | Frequencies() / Frequencies(keySelector) | returns a Dictionary<TKey, int> in first-seen order |
Enumerable.Shuffle() (.NET 10) | InRandomOrder(random) | takes a Random, so a seeded Random gives a repeatable order |
string[] statuses = { "Open", "Shipped", "Open" };
foreach ((string status, int index) in statuses.WithIndex())
{
// (status, index) → ("Open", 0), ("Shipped", 1), ("Open", 2)
}
Dictionary<string, int> counts = statuses.Frequencies(); // → { ["Open"] = 2, ["Shipped"] = 1 }
Random seeded = new Random(42);
List<string> randomOrder = statuses.InRandomOrder(seeded).ToList(); // same order for the same seed
Random.Shuffle(list) overlaps on purpose. .NET 8 added Random.Shuffle(T[]) and Random.Shuffle(Span<T>) as
instance methods, and an instance method always wins over an extension method. So on .NET 8+ an array binds to the
built-in method, while a List<T> or any other IList<T> binds to the library's extension. Both shuffle in place
with Fisher–Yates.
Random random = new Random(7);
List<string> deck = new List<string> { "A", "K", "Q", "J" };
random.Shuffle(deck); // the library's Shuffle(IList<T>); deck is reordered in place
See Querying sequences and Random for these methods.
Names shared with other libraries
A few names also exist in popular libraries. When both are in scope and both signatures fit, the compiler reports an ambiguous call (CS0121):
- System.Interactive (Ix) has
Scan,DistinctUntilChangedandIsEmptyonIEnumerable<T>. - FluentAssertions has
As<T>(), an extension onobject, like the library'sAs<T>(). - If your project imports its own
Page<T>, the type name is ambiguous (CS0104) with the library'sSystem.Collections.Generic.Page<T>.
Call the method statically, or give the class a using alias:
using MasterlyEnumerable = System.Collections.Generic.EnumerableExtensions;
decimal[] prices = { 10m, 12m, 11m };
// prices.Scan((sum, p) => sum + p) → CS0121 when System.Interactive is also referenced
IEnumerable<decimal> totals = EnumerableExtensions.Scan(prices, (sum, p) => sum + p); // → [10, 22, 33]
IEnumerable<decimal> same = MasterlyEnumerable.Scan(prices, (sum, p) => sum + p); // → [10, 22, 33]
object result = new Order();
// result.As<Order>() → CS0121 in a file with using FluentAssertions;
Order order = System.ObjectExtensions.As<Order>(result);
Order sameOrder = (Order)result; // or use a plain cast
using ApiPage = MyApp.Paging.Page<MyApp.Order>; // alias your own type
Receivers are not widened
int and long overloads
C# applies only identity, reference and boxing conversions to the receiver of an extension method. It does not apply
numeric conversions. So an extension on long cannot be called on an int, even though an int argument converts to
long everywhere else. That is why number helpers that make sense for both types have an int overload and a long
overload.
byte[] invoicePdf = new byte[1536];
long archiveBytes = 1_342_177_280L;
string pdfSize = invoicePdf.Length.ToFileSize(); // int → "1.5 KB"
string archiveSize = archiveBytes.ToFileSize(2); // long → "1.25 GB"
string small = 21.ToWords(); // int → "twenty-one"
string large = 5_000_000_000L.ToWords(); // long → "five billion"
bool evenPage = 1024.IsEven(); // → true
bool evenTicks = 1025L.IsEven(); // → false
Other numeric types (byte, short, uint, decimal, ...) do not bind to these overloads. Cast first, for
example ((int)retryCount).IsEven().
long-only receivers
Some methods only make sense for long, such as the Unix time converters. On an int value they need an L suffix
or a cast. The C# 14 static form, DateTime.FromUnixTimeSeconds(seconds), takes the value as an ordinary argument,
so an int converts there.
DateTime created = 1715774400L.FromUnixTimeSeconds(); // → 2024-05-15 12:00:00 (UTC)
int fromHeader = 1715774400;
// fromHeader.FromUnixTimeSeconds() → CS1929: int has no FromUnixTimeSeconds
DateTime parsed = ((long)fromHeader).FromUnixTimeSeconds(); // → 2024-05-15 12:00:00 (UTC)
DateTime viaStatic = DateTime.FromUnixTimeSeconds(fromHeader); // → 2024-05-15 12:00:00 (UTC)
DateTime.FromUnixTimeSeconds(...) is a C# 14 static extension member and needs a C# 14 compiler (the .NET 10 SDK).
The classic seconds.FromUnixTimeSeconds() works with any compiler. See Extension members.