Skip to main content

C# 14 Extension Members

C# 14 lets a library add properties, static members and operators to types it does not own. Masterly.Extensions.Core uses this for a few members that read better that way: name.IsBlank, "-" * 40, Guid.NewShortId(), TimeSpan.FromWeeks(2) and defaults + overrides. Each one has a classic equivalent that does the same work (usually an extension method, see What older compilers see), so you only need them if your compiler supports C# 14.

At a glance​

MemberWhat it does
text.IsBlanktrue when the string is null, empty or only white space.
string * intRepeats a string: "ab" * 3 is "ababab".
guid.IsEmptytrue when the value is Guid.Empty.
Guid.NewShortId()Creates a new random id as a 22-character URL-safe string.
TimeSpan.FromWeeksCreates a duration from a number of weeks.
DateTime.FromUnixTimeSecondsConverts Unix seconds to a UTC DateTime.
DateTime.FromUnixTimeMillisecondsConverts Unix milliseconds to a UTC DateTime.
dictionary + dictionaryMerges two dictionaries into a new one; the right side wins.
StringComparer.NaturalNatural-order comparer, case-sensitive ("file2" before "file10").
StringComparer.NaturalIgnoreCaseNatural-order comparer that ignores case.

What extension blocks are​

Before C# 14, an extension could only be an instance method: a static method whose first parameter has this. C# 14 adds the extension block. Inside it you declare members as if you were writing them on the extended type: properties, static methods, static properties and operators.

The library declares IsBlank and the * operator like this (simplified from StringExtensions.Members.cs):

How the library declares string members
namespace System;

public static partial class StringExtensions
{
extension(string? text)
{
// An instance extension property: called as name.IsBlank
public bool IsBlank => string.IsNullOrWhiteSpace(text);

// An extension operator: called as "ab" * 3
public static string operator *(string value, int count)
{
return value.Repeat(count);
}
}
}

There are two forms of block:

  • extension(string? text) names a receiver. Members inside are called on a value: name.IsBlank.
  • extension(Guid) has no receiver name. Members inside are static and called on the type: Guid.NewShortId().

The extension classes live in BCL namespaces (System, System.Collections.Generic), like the rest of the library. You get the members without adding a using. See Conventions.

Compiler requirements​

The members compile to ordinary static methods, so the package still runs on every target it supports (netstandard2.1 and net10.0). The requirement is on your compiler, not your runtime:

  • You need the C# 14 compiler, which ships with the .NET 10 SDK (and Visual Studio 2026).
  • Your project must use language version 14 or later. A net10.0 project gets C# 14 by default. A project that targets net8.0 or net9.0 defaults to an older language version, so set it yourself:
YourApp.csproj
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>14</LangVersion>
</PropertyGroup>

With the .NET 10 SDK and a lower language version, each use of the new syntax fails with an error such as:

Output
error CS9202: Feature 'extensions' is not available in C# 12.0. Please use language version 14.0 or greater.

An older SDK does not know the syntax at all, so it does not find the members. Every classic x.Method() extension in the library keeps working with any compiler.

What older compilers see​

An older compiler sees each member as a plain public static method on the extension class. You can call that method directly, or use the classic equivalent that does the same work:

C# 14 memberClassic equivalent (any C# version)Implementation method
text.IsBlanktext.IsNullOrWhiteSpace()StringExtensions.get_IsBlank(text)
text * counttext.Repeat(count)StringExtensions.op_Multiply(text, count)
guid.IsEmptyguid == Guid.EmptyGuidExtensions.get_IsEmpty(guid)
Guid.NewShortId()Guid.NewGuid().ToShortId()GuidExtensions.NewShortId()
TimeSpan.FromWeeks(weeks)weeks.Weeks()TimeSpanExtensions.FromWeeks(weeks)
DateTime.FromUnixTimeSeconds(seconds)seconds.FromUnixTimeSeconds()UnixTimeExtensions.FromUnixTimeSeconds(seconds)
DateTime.FromUnixTimeMilliseconds(ms)ms.FromUnixTimeMilliseconds()UnixTimeExtensions.FromUnixTimeMilliseconds(ms)
first + secondfirst.Merge(second)DictionaryExtensions.op_Addition(first, second)
StringComparer.NaturalNaturalStringComparer.OrdinalStringComparerExtensions.get_Natural()
StringComparer.NaturalIgnoreCaseNaturalStringComparer.OrdinalIgnoreCaseStringComparerExtensions.get_NaturalIgnoreCase()
The same checks without C# 14 syntax
string? customerName = null;

bool blank = customerName.IsNullOrWhiteSpace(); // → true
bool viaImplementation = StringExtensions.get_IsBlank(customerName); // → true
string rule = "-".Repeat(3); // → "---"
tip

Prefer the classic extension method over the get_ / op_ names. The implementation names help when a call is ambiguous, but they are harder to read.

String members​

text.IsBlank​

IsBlank is a property that is true when the string is null, empty or only white space. It is the same test as string.IsNullOrWhiteSpace. Use it in guards and filters where a property reads more naturally than a method call.

Validating a sign-up form
string? displayName = " ";
string? email = "ada@example.com";
string? referralCode = null;

bool nameMissing = displayName.IsBlank; // → true
bool emailMissing = email.IsBlank; // → false
bool noReferral = referralCode.IsBlank; // → true
bool emptyText = "".IsBlank; // → true
note

IsBlank is safe on a null string: it returns true and does not throw.

Nullable flow analysis

The compiler does not treat !name.IsBlank as a null check. Inside if (!name.IsBlank) { ... } it still warns that name may be null (CS8602). The classic name.IsNullOrWhiteSpace() carries [NotNullWhen(false)], so after if (!name.IsNullOrWhiteSpace()) the compiler knows name is not null. Use the method when you dereference the string right after the check.

When you need the null-state narrowing
public static int NameLength(string? name)
{
if (!name.IsNullOrWhiteSpace())
{
return name.Length; // no warning: the compiler knows name is not null
}

return 0;
}

string * int​

The * operator repeats a string a given number of times. It is the same as Repeat, described in String manipulation. Use it to draw rules, build indentation or pad plain-text output.

Building a plain-text invoice header
string rule = "-" * 10; // → "----------"
string title = "=" * 3 + " Invoice " + "=" * 3; // → "=== Invoice ==="
string indent = " " * 2; // → " "
string pattern = "ab" * 3; // → "ababab"
string nothing = "ab" * 0; // → ""
  • A count of 0 gives an empty string. An empty string repeated any number of times is also empty.
  • A negative count throws ArgumentOutOfRangeException (parameter count).
  • A null string throws ArgumentNullException.
  • The operator is defined only as string * int. 3 * "ab" does not compile.
Use a string, not a char

'-' * 40 is not a repeat. C# converts the char to its numeric code and multiplies two integers, so the result is the int 1800. Write "-" * 40, or use the classic '-'.Repeat(40).

Guid members​

guid.IsEmpty​

IsEmpty is a property that is true when the value equals Guid.Empty. Use it to spot entities that were never given an id.

Checking an order id before saving
Guid draftOrderId = Guid.Empty;
Guid savedOrderId = Guid.NewGuid();

bool isDraft = draftOrderId.IsEmpty; // → true
bool isSaved = savedOrderId.IsEmpty; // → false
note

The receiver is Guid, not Guid?. For a nullable id, check HasValue first or compare with Guid.Empty.

Guid.NewShortId()​

Guid.NewShortId() is a static member on Guid. It creates a new random Guid and returns it in the 22-character URL-safe form of ToShortId() (Base64url without padding). The id uses letters, digits, - and _, so it is safe in URLs and file names. Turn it back into a Guid with FromShortId().

Creating a short id for a shareable invoice link
string invoiceToken = Guid.NewShortId(); // → "gOu3dt2bZEu2vKeBQyeYlA" (random on each call)
int length = invoiceToken.Length; // → 22

Guid invoiceId = invoiceToken.FromShortId();
bool roundTrips = invoiceId.ToShortId() == invoiceToken; // → true

string emptyToken = Guid.Empty.ToShortId(); // → "AAAAAAAAAAAAAAAAAAAAAA"
tip

Need the Guid as well as the token? Create it with Guid.NewGuid() and call ToShortId() on it. NewShortId() returns only the string.

Time members​

TimeSpan.FromWeeks​

TimeSpan.FromWeeks(double weeks) is the factory that TimeSpan does not have. One week is seven days, and fractions are allowed. It is the same as the classic weeks.Weeks() described in Durations.

Setting subscription and trial periods
TimeSpan billingCycle = TimeSpan.FromWeeks(2);
double cycleDays = billingCycle.TotalDays; // → 14

TimeSpan trial = TimeSpan.FromWeeks(1.5);
double trialDays = trial.TotalDays; // → 10.5

double halfWeekHours = TimeSpan.FromWeeks(0.5).TotalHours; // → 84
  • A result too large for a TimeSpan throws OverflowException.
  • double.NaN throws ArgumentException, as TimeSpan.FromDays does.

DateTime.FromUnixTimeSeconds / FromUnixTimeMilliseconds​

These static members on DateTime convert a Unix timestamp to a DateTime with DateTimeKind.Utc. They work the way DateTimeOffset.FromUnixTimeSeconds and FromUnixTimeMilliseconds do, but return a DateTime. Use them for timestamps from APIs, JWT claims, payment webhooks or JavaScript's Date.now().

Reading timestamps from a payment webhook
DateTime paidAt = DateTime.FromUnixTimeSeconds(1715774400);
string paidAtIso = paidAt.ToString("O"); // → "2024-05-15T12:00:00.0000000Z"
DateTimeKind kind = paidAt.Kind; // → DateTimeKind.Utc

DateTime refundedAt = DateTime.FromUnixTimeMilliseconds(1715774400123);
string refundedAtIso = refundedAt.ToString("O"); // → "2024-05-15T12:00:00.1230000Z"

DateTime epoch = DateTime.FromUnixTimeSeconds(0);
string epochIso = epoch.ToString("O"); // → "1970-01-01T00:00:00.0000000Z"

DateTime dayBeforeEpoch = DateTime.FromUnixTimeSeconds(-86400);
string beforeIso = dayBeforeEpoch.ToString("O"); // → "1969-12-31T00:00:00.0000000Z"
  • Negative values are allowed and give dates before 1970.
  • A value outside the DateTime range (year 1 to year 9999) throws ArgumentOutOfRangeException.
  • The parameter is a long, so an int literal such as 1715774400 works here without a suffix.
Why a separate class

These members live in the class UnixTimeExtensions, not DateTimeExtensions. An extension member compiles to a static method, and DateTimeExtensions already has FromUnixTimeSeconds(this long) as a classic extension. With that classic method, an int literal needs an L suffix (1715774400L.FromUnixTimeSeconds()), because C# does not widen the receiver of an extension method. See Dates.

Dictionary operator​

dictionary + dictionary​

The + operator combines two dictionaries into a new Dictionary<TKey, TValue>. When a key is in both, the value from the right side wins. It is the same as Merge, described in Dictionaries, and fits the "defaults, then overrides" pattern for settings.

Applying user preferences over defaults
Dictionary<string, string> defaults = new Dictionary<string, string>
{
["Theme"] = "light",
["PageSize"] = "20",
};

Dictionary<string, string> userPreferences = new Dictionary<string, string>
{
["PageSize"] = "50",
["Language"] = "ar",
};

Dictionary<string, string> settings = defaults + userPreferences;

string pageSize = settings["PageSize"]; // → "50"
string theme = settings["Theme"]; // → "light"
string language = settings["Language"]; // → "ar"
int defaultsCount = defaults.Count; // → 2 (the operands are not changed)

The operator is declared on IReadOnlyDictionary<TKey, TValue>, so it works with Dictionary, SortedDictionary, ConcurrentDictionary and any other type that implements that interface. Compound assignment works too:

Layering several sources of limits
Dictionary<string, int> limits = new Dictionary<string, int> { ["MaxItems"] = 100, ["MaxUsers"] = 5 };
Dictionary<string, int> planLimits = new Dictionary<string, int> { ["MaxUsers"] = 25 };
Dictionary<string, int> customerLimits = new Dictionary<string, int> { ["MaxItems"] = 500 };

limits += planLimits;
limits += customerLimits;

int maxItems = limits["MaxItems"]; // → 500
int maxUsers = limits["MaxUsers"]; // → 25
  • Either side being null throws ArgumentNullException.
  • Each + builds a new dictionary. limits += planLimits points limits at the new dictionary; it does not change the old one in place.
  • To decide yourself what happens on a key conflict (for example, add the values), call Merge with a resolver: stockA.Merge(stockB, (sku, a, b) => a + b).
The key comparer is not kept

The result always uses the default comparer for TKey. If defaults was created with StringComparer.OrdinalIgnoreCase, the merged dictionary is case-sensitive: settings.ContainsKey("theme") is false when the key was added as "Theme". If case matters, copy the result into a new dictionary that uses the comparer you need.

Natural-order comparers​

StringComparer.Natural / NaturalIgnoreCase​

StringComparer.Natural and StringComparer.NaturalIgnoreCase are static extension properties. They return NaturalStringComparer.Ordinal and NaturalStringComparer.OrdinalIgnoreCase, so the natural-order comparers sit next to the built-in StringComparer.Ordinal. In natural order, runs of digits compare by numeric value: "file2" comes before "file10", and "v1.2.9" before "v1.2.10". Everything else compares ordinally.

Sorting codes, versions and invoice numbers
string[] codes = { "b10", "b2", "B3" };

List<string> caseSensitive = codes.OrderBy(code => code, StringComparer.Natural).ToList();
// → ["B3", "b2", "b10"]

List<string> ignoringCase = codes.OrderBy(code => code, StringComparer.NaturalIgnoreCase).ToList();
// → ["b2", "B3", "b10"]

List<string> versions = new List<string> { "v1.2.10", "v1.2.9", "v1.10.0" };
versions.Sort(StringComparer.Natural);
// → ["v1.2.9", "v1.2.10", "v1.10.0"]

SortedDictionary<string, decimal> invoiceTotals = new SortedDictionary<string, decimal>(StringComparer.Natural)
{
["INV-10"] = 120.00m,
["INV-9"] = 75.50m,
["INV-100"] = 19.99m,
};
List<string> invoiceNumbers = invoiceTotals.Keys.ToList();
// → ["INV-9", "INV-10", "INV-100"]
  • Natural is case-sensitive and ordinal, so upper-case letters sort before lower-case ones: "B3" comes before "b2".
  • null sorts first. Digit runs of any length compare without overflow.
  • The comparers also implement IEqualityComparer<string?>, so they work in HashSet, Dictionary and Distinct. Numbers with different leading zeros are not equal: "Page01" and "page1" are two different keys, even with NaturalIgnoreCase.
  • NaturalStringComparer does not derive from StringComparer. Pass it where an IComparer<string> or IEqualityComparer<string> is expected.
tip

To sort a plain list of strings, names.OrderByNatural() is shorter. See Fuzzy matching and natural sort.

See also​

  • Conventions: namespaces, null handling and culture rules shared by every member.
  • String predicates: IsNullOrWhiteSpace and the other string checks.
  • Dictionaries: Merge with a conflict resolver and other dictionary helpers.
  • Dates: Unix time conversion in both directions and other DateTime helpers.