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
| Member | What it does |
|---|---|
text.IsBlank | true when the string is null, empty or only white space. |
string * int | Repeats a string: "ab" * 3 is "ababab". |
guid.IsEmpty | true when the value is Guid.Empty. |
Guid.NewShortId() | Creates a new random id as a 22-character URL-safe string. |
TimeSpan.FromWeeks | Creates a duration from a number of weeks. |
DateTime.FromUnixTimeSeconds | Converts Unix seconds to a UTC DateTime. |
DateTime.FromUnixTimeMilliseconds | Converts Unix milliseconds to a UTC DateTime. |
dictionary + dictionary | Merges two dictionaries into a new one; the right side wins. |
StringComparer.Natural | Natural-order comparer, case-sensitive ("file2" before "file10"). |
StringComparer.NaturalIgnoreCase | Natural-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):
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.0project gets C# 14 by default. A project that targetsnet8.0ornet9.0defaults to an older language version, so set it yourself:
<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:
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 member | Classic equivalent (any C# version) | Implementation method |
|---|---|---|
text.IsBlank | text.IsNullOrWhiteSpace() | StringExtensions.get_IsBlank(text) |
text * count | text.Repeat(count) | StringExtensions.op_Multiply(text, count) |
guid.IsEmpty | guid == Guid.Empty | GuidExtensions.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 + second | first.Merge(second) | DictionaryExtensions.op_Addition(first, second) |
StringComparer.Natural | NaturalStringComparer.Ordinal | StringComparerExtensions.get_Natural() |
StringComparer.NaturalIgnoreCase | NaturalStringComparer.OrdinalIgnoreCase | StringComparerExtensions.get_NaturalIgnoreCase() |
string? customerName = null;
bool blank = customerName.IsNullOrWhiteSpace(); // → true
bool viaImplementation = StringExtensions.get_IsBlank(customerName); // → true
string rule = "-".Repeat(3); // → "---"
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.
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
IsBlank is safe on a null string: it returns true and does not throw.
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.
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.
string rule = "-" * 10; // → "----------"
string title = "=" * 3 + " Invoice " + "=" * 3; // → "=== Invoice ==="
string indent = " " * 2; // → " "
string pattern = "ab" * 3; // → "ababab"
string nothing = "ab" * 0; // → ""
- A count of
0gives an empty string. An empty string repeated any number of times is also empty. - A negative count throws
ArgumentOutOfRangeException(parametercount). - A
nullstring throwsArgumentNullException. - The operator is defined only as
string * int.3 * "ab"does not compile.
'-' * 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.
Guid draftOrderId = Guid.Empty;
Guid savedOrderId = Guid.NewGuid();
bool isDraft = draftOrderId.IsEmpty; // → true
bool isSaved = savedOrderId.IsEmpty; // → false
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().
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"
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.
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
TimeSpanthrowsOverflowException. double.NaNthrowsArgumentException, asTimeSpan.FromDaysdoes.
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().
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
DateTimerange (year 1 to year 9999) throwsArgumentOutOfRangeException. - The parameter is a
long, so anintliteral such as1715774400works here without a suffix.
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.
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:
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
nullthrowsArgumentNullException. - Each
+builds a new dictionary.limits += planLimitspointslimitsat 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
Mergewith a resolver:stockA.Merge(stockB, (sku, a, b) => a + b).
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.
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"]
Naturalis case-sensitive and ordinal, so upper-case letters sort before lower-case ones:"B3"comes before"b2".nullsorts first. Digit runs of any length compare without overflow.- The comparers also implement
IEqualityComparer<string?>, so they work inHashSet,DictionaryandDistinct. Numbers with different leading zeros are not equal:"Page01"and"page1"are two different keys, even withNaturalIgnoreCase. NaturalStringComparerdoes not derive fromStringComparer. Pass it where anIComparer<string>orIEqualityComparer<string>is expected.
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:
IsNullOrWhiteSpaceand the other string checks. - Dictionaries:
Mergewith a conflict resolver and other dictionary helpers. - Dates: Unix time conversion in both directions and other
DateTimehelpers.