Skip to main content

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​

MethodWhat it does
Window, Age, JoinWithInvalid arguments throw ArgumentNullException / ArgumentOutOfRangeException with the parameter name
Window, Scan, WithIndex, InRandomOrderLazy sequences check their arguments when you call them, not when you enumerate
ContainsIgnoreCase, StartsWithIgnoreCase, EqualsIgnoreCaseString predicates return false for a null string
ToKebabCase, SplitWords, FirstCharToUpperThe receiver's nullable annotation tells you whether null is allowed
EndsWithIgnoreCase, ContainsAny, GetPropertyValueComparisons are ordinal unless you pass a StringComparison
ToQueryString, JoinWith, ToFileSize, TryParse<T>Formatting and parsing use the invariant culture
ToCamelCase, FormatWith, ToTableThe few places where the current culture is used, or can be chosen
ToRelativeTime, Age, After, BeforeNothing reads the system clock; you pass "now"
GetPropertyValue, SetPropertyValue, GetAttribute<T>Extensions live in the namespace of the type they extend
WithIndex, Frequencies, InRandomOrder, ShuffleNames avoid what .NET ships, such as Index, CountBy and Shuffle
Scan, As<T>, DistinctUntilChanged, IsEmptyNames shared with System.Interactive and FluentAssertions, and how to choose one
ToFileSize, ToWords, IsEvenReceivers are not widened, so number helpers have int and long overloads
FromUnixTimeSecondsA 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.

Exceptions carry the parameter name
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"
note

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.

The exception is thrown before enumeration
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:

Valid calls stay lazy
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.

A null string is simply not a match
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
info

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? passes null through, string rejects it
string? nickname = null;

string? slug = nickname.ToKebabCase(); // → null
string[] words = nickname!.SplitWords(); // → ArgumentNullException
string first = nickname!.FirstCharToUpper(); // → ArgumentNullException
tip

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.

Ordinal by default, explicit when you ask
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:

Property names 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.

Same output on every machine
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.

note

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. Pass useCurrentCulture: true to use the current culture's rules.
  • FormatWith(source, provider) uses the current culture when provider is null, which is the default.
  • ToTable, ToMarkdownTable and ToHtmlTable format cell values with the current culture, because a person reads them on a console, in a log or on a page.
Turkish casing only when you ask for it
using System.Globalization;

using IDisposable turkish = CultureInfo.GetCultureInfo("tr-TR").Use();

string? invariant = "ID".ToCamelCase(); // → "id"
string? local = "ID".ToCamelCase(useCurrentCulture: true); // → "ıd"
Tables follow the reader's culture
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.

The caller supplies the reference time
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)
tip

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.

NamespaceClasses
SystemStringExtensions, NumberExtensions, DateTimeExtensions, TimeSpanExtensions, ObjectExtensions, TypeExtensions, EnumExtensions, GuidExtensions, RandomExtensions, ExceptionExtensions, FunctionExtensions, ...
System.Collections.GenericEnumerableExtensions, ListExtensions, DictionaryExtensions, Page<T>, CollectionDiff<T>, ...
System.LinqQueryableExtensions, GroupingExtensions
System.Threading.TasksAsyncExtensions, AwaitableExtensions, Retry, TaskTupleExtensions
System.ThreadingSemaphoreSlimExtensions
System.IOStreamExtensions, TextReaderExtensions
System.DiagnosticsProcessExtensions
System.GlobalizationCultureInfoExtensions
System.Collections.SpecializedNameValueCollectionExtensions
System.ReflectionObjectExtensions (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:

Reflection helpers need System.Reflection
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"
warning

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 methodLibrary methodDifference
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
Library names next to their .NET counterparts
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.

Shuffle an IList<T> in place
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, DistinctUntilChanged and IsEmpty on IEnumerable<T>.
  • FluentAssertions has As<T>(), an extension on object, like the library's As<T>().
  • If your project imports its own Page<T>, the type name is ambiguous (CS0104) with the library's System.Collections.Generic.Page<T>.

Call the method statically, or give the class a using alias:

Resolving an ambiguous Scan
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]
As<T> in a FluentAssertions test
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
Two Page<T> types
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.

Both int and long receivers work
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.

Unix timestamps are long
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)
info

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.

See also​