Skip to main content

Upgrading from 1.x

Most 1.x code still compiles against 2.0. But some methods now return different results, two classes have new names, the target frameworks changed and the package no longer has dependencies. This page lists every breaking change by area, with the 1.x code next to the 2.0 code. Read it once when you move to version 2.0.

At a glance​

MethodWhat it does
Target frameworksnetstandard2.1 and net10.0 replace the 1.x builds
DependenciesNo third-party packages; System.Text.Json on netstandard2.1 only
StringExtensions, EnumerableExtensionsThe misspelled 1.x class names are fixed; static calls must be renamed
ToPascalCase, ToCamelCaseNormalize every word, not just the first character
ToSentenceCaseCapitalizes the first word
ToCamelCase(bool, bool)Obsolete; acronyms are always handled
Contains, StartsWith, EndsWith, *IgnoreCaseThrow for a null value to look for; compare ordinally
ContainsIgnoreCase(StringComparison, ...)Obsolete; use ContainsAny(comparison, ...)
EqualsIgnoreCaseTwo null strings are equal
LeftThrows ArgumentOutOfRangeException when the length is too large
FirstCharToUpperReturns "" for an empty string
ReplaceFirstThrows ArgumentException for an empty search value
ToEnum<T>The constraint is now where T : struct, Enum
AddFirst, AddLast, InsertBefore, InsertAfter, Replace*Accept null items
MoveItemReturns bool and works on any IList<T>
ToObject<T>Takes IDictionary<string, object?>, ignores unknown keys, converts values
IDictionary.ToQueryStringPercent-encodes, uses the invariant culture and ISO 8601 dates
NameValueCollection.ToQueryStringWrites spaces as %20; the pairs overload takes any IEnumerable
object.ToQueryStringNew collectionSeparator parameter; repeats keys by default
ToJsonStringUses System.Text.Json instead of Newtonsoft.Json
Is(Type)True for derived types too
IsNumberReturns false for null
IsIn, IfAccept a null receiver
GetPropertyValue, SetPropertyValueThrow ArgumentException for unknown or read-only properties
IsComplexDateTimeOffset, TimeSpan, Uri, DateOnly, TimeOnly are simple values
Invalid argumentsAlways ArgumentNullException / ArgumentOutOfRangeException with the parameter name

Upgrade checklist​

  1. Change the package version to 2.0.0. Check that your target framework is still supported (Target frameworks).
  2. Build. Fix the compile errors from the class renames, ToEnum<T>, object.ToQueryString and any package you used through this one (Dependencies).
  3. Fix the CS0618 obsolete warnings (ToCamelCase(bool, bool), ContainsIgnoreCase(StringComparison, ...)). If your project treats warnings as errors, these stop the build.
  4. Search for the methods whose results changed. Check what depends on their output: stored data, URLs, JSON contracts and tests.
  5. Rebuild every library of yours that references the package. Assemblies compiled against 1.x are not binary compatible with 2.0.

This search finds most call sites that need a look:

Find call sites affected by 2.0
git grep -nE "StringExtentions|EnumerableExtenstions|ToPascalCase|ToCamelCase|ToSentenceCase|ToJsonString|ToQueryString|ToObject<|MoveItem|IsComplex|ReplaceFirst|EqualsIgnoreCase"
Recompile, do not just swap the DLL

In compiled code an extension method call is a static call on its class, such as StringExtentions.ToKebabCase(name). A library built against 1.x still refers to the old class names and the old MoveItem return type. Run against 2.0, it fails with TypeLoadException or MissingMethodException. Rebuild such libraries against 2.0.

Package and platform​

Target frameworks​

The 1.x source targeted netstandard2.1, netcoreapp3.0 and net5.0, but the 1.0.0 package on nuget.org contains a single netcoreapp3.1 build. 2.0 ships netstandard2.1 and net10.0. NuGet picks the closest build for your app, so every .NET Core 3.0 or later app can still install the package:

Your app targetsBuild it uses
.NET Core 3.0, 3.1, .NET 5 to .NET 9netstandard2.1
.NET 10 and laternet10.0
.NET FrameworkNot supported: it cannot run netstandard2.1 (the same as in 1.x)
Before (1.x)
<PackageReference Include="Masterly.Extensions.Core" Version="1.0.0" />
After (2.0)
<PackageReference Include="Masterly.Extensions.Core" Version="2.0.0" />

The net10.0 build is trim- and Native AOT-compatible. Reflection and JSON members are annotated, so the trim analyzer warns where you call them. See Trimming and AOT.

Dependencies​

1.x depended on Ardalis.GuardClauses, JetBrains.Annotations and Newtonsoft.Json. 2.0 has no third-party dependencies. On netstandard2.1 it references System.Text.Json. On net10.0 it references nothing.

Did your code use one of those packages without referencing it? Then it compiled in 1.x only because the package came in through this one. In 2.0 those using directives fail with CS0246. Add the package yourself:

After (2.0): reference what you use
<PackageReference Include="Masterly.Extensions.Core" Version="2.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />

Class names​

Two 1.x classes had misspelled names: StringExtentions and EnumerableExtenstions. 2.0 names them StringExtensions and EnumerableExtensions. They stay in the System and System.Collections.Generic namespaces.

Extension-method calls such as name.ToKebabCase() compile without changes. Only code that names the class must change: static calls, using static directives, and nameof or typeof expressions.

Before (1.x)
using static System.StringExtentions;

List<Order> orders = new List<Order>();

string slug = StringExtentions.ToKebabCase("OrderLineItem");
bool noOrders = EnumerableExtenstions.IsEmpty(orders);
After (2.0)
using static System.StringExtensions;

List<Order> orders = new List<Order>();

string slug = StringExtensions.ToKebabCase("OrderLineItem"); // → "order-line-item"
bool noOrders = EnumerableExtensions.IsEmpty(orders); // → true

// Simpler: call them as extension methods, which never name the class.
string sameSlug = "OrderLineItem".ToKebabCase(); // → "order-line-item"
bool stillNoOrders = orders.IsEmpty(); // → true

String casing​

ToPascalCase and ToCamelCase​

In 1.x these methods changed only the first character. In 2.0 they split the text into words (at case changes, digits, _, - and spaces) and rebuild it. Acronyms become normal words, and separators are removed.

Before (1.x)
string column = "customer_first_name".ToPascalCase(); // → "Customer_first_name"
string header = "HTTPResponse".ToPascalCase(); // → "HTTPResponse"
string field = "HTTPResponse".ToCamelCase(); // → "hTTPResponse"
string key = "CustomerID".ToCamelCase(); // → "customerID"
After (2.0)
string column = "customer_first_name".ToPascalCase(); // → "CustomerFirstName"
string header = "HTTPResponse".ToPascalCase(); // → "HttpResponse"
string field = "HTTPResponse".ToCamelCase(); // → "httpResponse"
string key = "CustomerID".ToCamelCase(); // → "customerId"

Input that is already in the target style usually stays the same: "OrderId".ToCamelCase() returns "orderId" in both versions. null, empty and white-space input is still returned as is.

Check names you store or send

Do you build JSON property names, column names, cache keys or URLs with these methods? Compare the old and the new output. For example, "CustomerID".ToCamelCase() used to return "customerID" and now returns "customerId".

Keeping the old ToPascalCase output

1.x ToPascalCase upper-cased the first character with the invariant culture. FirstCharToUpper() does the same for a non-null string: "customer_first_name".FirstCharToUpper() returns "Customer_first_name". Unlike ToPascalCase, it throws ArgumentNullException for null.

More examples are on String casing.

ToSentenceCase​

In 1.x, ToSentenceCase kept the first character's case and split words only at a lower-case to upper-case change. In 2.0 it uses the same word splitting as the other casing methods and capitalizes the first word.

Before (1.x)
string label = "orderStatus".ToSentenceCase(); // → "order status"
string title = "HTTPResponse".ToSentenceCase(); // → "HTTPResponse"
string text = "ThisIsSampleSentence".ToSentenceCase(); // → "This is sample sentence"
After (2.0)
string label = "orderStatus".ToSentenceCase(); // → "Order status"
string title = "HTTPResponse".ToSentenceCase(); // → "Http response"
string text = "ThisIsSampleSentence".ToSentenceCase(); // → "This is sample sentence"

ToCamelCase(bool, bool)​

1.x had ToCamelCase(useCurrentCulture = false, handleAbbreviations = false). With handleAbbreviations: true, an all-upper-case string was lowered completely. 2.0 always handles acronyms. The three-argument overload is marked [Obsolete] and ignores the flag.

Before (1.x)
string code = "HTTP".ToCamelCase(false, true); // → "http"
string sameCode = "HTTP".ToCamelCase(handleAbbreviations: true); // → "http"
After (2.0)
string code = "HTTP".ToCamelCase(); // → "http"
string localCode = "HTTP".ToCamelCase(useCurrentCulture: true); // → "http"
note

"HTTP".ToCamelCase(false, true) still compiles in 2.0, with warning CS0618. A call that names only handleAbbreviations: does not compile, because the obsolete overload has no default values. Remove the argument.

String predicates​

Contains, StartsWith, EndsWith and the IgnoreCase variants​

In 1.x every string predicate caught all exceptions and returned false. A null search value looked like "no match". In 2.0 the rules are:

  • A null string (the receiver) still returns false.
  • A null value to look for, or a null array, throws ArgumentNullException with ParamName "toCheck".
  • All comparisons are ordinal. In 1.x the StartsWith(params string[]) and EndsWith(params string[]) overloads used a culture-sensitive comparison.

These rules cover Contains, ContainsIgnoreCase, StartsWith, StartsWithIgnoreCase, EndsWith and EndsWithIgnoreCase.

Before (1.x)
string customerNote = null;
string couponCode = null;

bool flagged = customerNote.Contains("refund", "chargeback"); // → false
bool hasCoupon = "Invoice overdue".Contains("paid", couponCode); // → false (the exception was swallowed)
After (2.0)
string? customerNote = null;
string? couponCode = null;

bool flagged = customerNote.Contains("refund", "chargeback"); // → false
bool hasCoupon = "Invoice overdue".Contains("paid", couponCode!); // throws ArgumentNullException ("toCheck")
One value binds to the string method

text.Contains("paid") and text.StartsWith("INV") with a single string call the string instance methods, not these extensions, in both versions. They throw NullReferenceException when text is null. To get the extension and its null-receiver rule, pass two or more values, or use the IgnoreCase methods.

See String predicates for every overload.

ContainsIgnoreCase with a comparison​

The overloads ContainsIgnoreCase(StringComparison, string) and ContainsIgnoreCase(StringComparison, params string[]) are obsolete. The name says "ignore case", but the argument can say otherwise. Use ContainsAny, which takes the same arguments.

Before (1.x)
string ticketTitle = "Customer requests a REFUND";

bool isRefund = ticketTitle.ContainsIgnoreCase(StringComparison.OrdinalIgnoreCase, "refund", "chargeback");
After (2.0)
string ticketTitle = "Customer requests a REFUND";

bool isRefund = ticketTitle.ContainsAny(StringComparison.OrdinalIgnoreCase, "refund", "chargeback"); // → true
bool sameCheck = ticketTitle.ContainsIgnoreCase("refund", "chargeback"); // → true

The ContainsIgnoreCase overloads without a StringComparison are not obsolete.

EqualsIgnoreCase​

In 1.x, EqualsIgnoreCase on a null string returned false, even when the other string was also null. In 2.0 it calls string.Equals(a, b, StringComparison.OrdinalIgnoreCase), so two null strings are equal.

Before (1.x)
string billingEmail = null;
string shippingEmail = null;

bool same = billingEmail.EqualsIgnoreCase(shippingEmail); // → false
After (2.0)
string? billingEmail = null;
string? shippingEmail = null;

bool same = billingEmail.EqualsIgnoreCase(shippingEmail); // → true
bool oneMissing = billingEmail.EqualsIgnoreCase("amal@example.com"); // → false

String manipulation​

Left​

In 1.x, Left(len) with a length larger than the string threw ArgumentException. In 2.0 it throws ArgumentOutOfRangeException with ParamName "len", and it also rejects a negative length. Right follows the same rule.

Before (1.x)
string prefix;
try
{
prefix = "INV".Left(10);
}
catch (ArgumentException)
{
prefix = "INV";
}
After (2.0)
string invoiceNumber = "INV-2024-0042";

string prefix = invoiceNumber.Left(3); // → "INV"
string tooLong = "INV".Left(10); // throws ArgumentOutOfRangeException ("len")

ArgumentOutOfRangeException derives from ArgumentException, so the 1.x catch block above still works. A check for the exact type, such as ex.GetType() == typeof(ArgumentException), does not.

FirstCharToUpper​

In 1.x, "".FirstCharToUpper() threw ArgumentException. In 2.0 it returns the empty string. A null input still throws ArgumentNullException.

Before (1.x)
string city = "";

string display = city.Length == 0 ? city : city.FirstCharToUpper();
After (2.0)
string city = "";

string display = city.FirstCharToUpper(); // → ""
string capital = "amman".FirstCharToUpper(); // → "Amman"

ReplaceFirst​

In 1.x an empty search value matched at position 0, so the replacement was inserted at the start. 2.0 throws ArgumentException with ParamName "search". A null replacement is now allowed and removes the match.

Before (1.x)
string sku = "order".ReplaceFirst("", "x"); // → "xorder"
After (2.0)
string sku = "order".ReplaceFirst("", "x"); // throws ArgumentException ("search")
string reference = "order-123-123".ReplaceFirst("123", "456"); // → "order-456-123"

Parsing​

ToEnum​

1.x ToEnum<T> had the constraint where T : struct, so "5".ToEnum<int>() compiled and failed at run time. 2.0 uses where T : struct, Enum. Calls with a concrete enum type are unchanged. A generic method that passes on its own T must add the Enum constraint.

Before (1.x)
public static T ParseStatus<T>(string text)
where T : struct
{
return text.ToEnum<T>(ignoreCase: true);
}
After (2.0)
public static T ParseStatus<T>(string text)
where T : struct, Enum
{
return text.ToEnum<T>(ignoreCase: true);
}

OrderStatus status = ParseStatus<OrderStatus>("paid"); // → OrderStatus.Paid

Lists​

Null items​

In 1.x, AddFirst, AddLast, InsertBefore, InsertAfter, ReplaceWhile and ReplaceOne threw ArgumentNullException for a null item, and for a null existing item. In 2.0 they accept null, as IList<T>.Insert does. Only the list and the delegates are checked.

Before (1.x)
List<string> reviewers = new List<string> { "amal", "omar" };

reviewers.AddFirst(null); // throws ArgumentNullException ("item")
After (2.0)
List<string?> reviewers = new List<string?> { "amal", "omar" };

reviewers.AddFirst(null);
reviewers.InsertAfter("amal", null);
// reviewers → [null, "amal", null, "omar"]

If you relied on the exception to keep null out of a list, check the item yourself before the call.

note

ReplaceOne(item, replaceWith) now finds the item by equality (IList<T>.IndexOf). In 1.x it used Comparer<T>.Default, which throws for types that do not implement IComparable.

MoveItem​

In 1.x, MoveItem returned void, was defined on List<T> only, and threw ArgumentOutOfRangeException when no item matched. 2.0 returns bool: true when an item was found, false when nothing matched. It works on any IList<T> that supports inserting and removing, such as Collection<T> or ObservableCollection<T>.

Before (1.x)
List<string> steps = new List<string> { "draft", "review", "publish" };

if (steps.Contains("archive"))
{
steps.MoveItem(step => step == "archive", 0);
}
After (2.0)
List<string> steps = new List<string> { "draft", "review", "publish" };

bool moved = steps.MoveItem(step => step == "publish", 0); // → true
// steps → ["publish", "draft", "review"]

bool missing = steps.MoveItem(step => step == "archive", 0); // → false

A targetIndex outside the list still throws ArgumentOutOfRangeException. Code that ignores the result compiles unchanged, but it must be recompiled (see the checklist). See Lists and collections.

Dictionaries and query strings​

The examples in this section and the next ones use these types:

Types used below
public sealed class Invoice
{
public string? Number { get; set; }

public decimal Total { get; set; }
}

public sealed class CustomerFilter
{
public string? City { get; set; }
}

public sealed class OrderFilter
{
public string? Status { get; set; }

public int[]? Ids { get; set; }

public CustomerFilter? Customer { get; set; }
}

ToObject​

ToObject<T> now extends IDictionary<string, object?> instead of IDictionary<string, object>. It also:

  • accepts an empty dictionary and returns a new T with default values (1.x threw ArgumentException);
  • ignores keys that match no public writable property (1.x threw NullReferenceException);
  • converts each value to the property type with the invariant culture, so "99.5" fills a decimal (1.x threw ArgumentException when the types did not match).
Before (1.x)
IDictionary<string, object> row = new Dictionary<string, object>
{
["Number"] = "INV-7",
["Total"] = 99.5m,
};

Invoice invoice = row.ToObject<Invoice>();
After (2.0)
IDictionary<string, object?> row = new Dictionary<string, object?>
{
["Number"] = "INV-7",
["Total"] = "99.5",
["Source"] = "csv",
};

Invoice invoice = row.ToObject<Invoice>();
// invoice.Number → "INV-7"
// invoice.Total → 99.5

Invoice blank = new Dictionary<string, object?>().ToObject<Invoice>();
// blank.Number → null

A value that cannot be converted throws FormatException ("abc" for a decimal) or InvalidCastException (no conversion exists, or null for a non-nullable value type). Declare your dictionaries with object? values to match the new signature. See Dictionaries.

IDictionary.ToQueryString​

1.x joined key=value pairs as they were: no encoding, numbers in the current culture, DateTime as MM/dd/yyyy HH:mm:ss. An empty dictionary threw ArgumentException. 2.0:

  • percent-encodes keys and values (spaces become %20);
  • formats numbers with the invariant culture;
  • writes DateTime and DateTimeOffset in the ISO 8601 round-trip format;
  • returns "" for an empty dictionary;
  • writes a null value as key=, and repeats the key for each item of a collection value.
Before (1.x), current culture de-DE
Dictionary<string, object> query = new Dictionary<string, object>
{
["customer"] = "John Doe",
["total"] = 1.5m,
["since"] = new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc),
};

string queryString = query.ToQueryString();
// → "customer=John Doe&total=1,5&since=01/02/2024 03:04:05"
After (2.0), any culture
Dictionary<string, object?> query = new Dictionary<string, object?>
{
["customer"] = "John Doe",
["total"] = 1.5m,
["since"] = new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc),
};

string queryString = query.ToQueryString();
// → "customer=John%20Doe&total=1.5&since=2024-01-02T03%3A04%3A05.0000000Z"

Dictionary<string, object?> tagged = new Dictionary<string, object?>
{
["tags"] = new[] { "urgent", "vip" },
["note"] = null,
};

string tags = tagged.ToQueryString(); // → "tags=urgent&tags=vip&note="
Remove your own encoding

If you called Uri.EscapeDataString on values before passing them in, remove that call. Otherwise the values are encoded twice (%20 becomes %2520). Also check that the receiving server reads ISO 8601 dates. ASP.NET Core model binding does.

NameValueCollection.ToQueryString​

1.x encoded with HttpUtility.UrlEncode, which writes a space as +. 2.0 writes %20. The second overload took a KeyValuePair<string, string>[] in 1.x. In 2.0 it takes any IEnumerable<KeyValuePair<string, string?>>, so a Dictionary<string, string?> works directly.

Before (1.x)
NameValueCollection search = new NameValueCollection { { "q", "red shoes" } };
string fromCollection = search.ToQueryString(); // → "q=red+shoes"

KeyValuePair<string, string>[] pairs = { new KeyValuePair<string, string>("q", "red shoes") };
string fromPairs = pairs.ToQueryString(); // → "q=red+shoes"
After (2.0)
NameValueCollection search = new NameValueCollection { { "q", "red shoes" } };
string fromCollection = search.ToQueryString(); // → "q=red%20shoes"

Dictionary<string, string?> pairs = new Dictionary<string, string?> { ["q"] = "red shoes" };
string fromPairs = pairs.ToQueryString(); // → "q=red%20shoes"

Servers decode both forms to the same text. The change matters when you compare query strings as text, for example in tests or signed URLs.

object.ToQueryString​

The parameter separator (default "&") is now collectionSeparator (default null):

  • null repeats the key for each collection value: Ids=1&Ids=2. ASP.NET Core binds this to an array.
  • Any other value joins the collection into one encoded value, as 1.x did.
  • Nested objects are written as Parent.Child=..., and lists of objects as Items[0].Name=... (the brackets are percent-encoded: Items%5B0%5D.Name=...).
  • Values use the invariant culture and ISO 8601 dates. A graph deeper than 16 levels, usually a cycle, throws InvalidOperationException.
Before (1.x)
OrderFilter filter = new OrderFilter
{
Status = "open",
Ids = new[] { 1, 2 },
Customer = new CustomerFilter { City = "Amman" },
};

string byDefault = filter.ToQueryString();
// → "Status=open&Ids=1%262&Customer.City=Amman"

string commaSeparated = filter.ToQueryString(separator: ",");
// → "Status=open&Ids=1%2C2&Customer.City=Amman"
After (2.0)
OrderFilter filter = new OrderFilter
{
Status = "open",
Ids = new[] { 1, 2 },
Customer = new CustomerFilter { City = "Amman" },
};

string byDefault = filter.ToQueryString();
// → "Status=open&Ids=1&Ids=2&Customer.City=Amman"

string commaSeparated = filter.ToQueryString(collectionSeparator: ",");
// → "Status=open&Ids=1%2C2&Customer.City=Amman"

string asInVersion1 = filter.ToQueryString("&");
// → "Status=open&Ids=1%262&Customer.City=Amman"

A positional argument such as filter.ToQueryString(",") compiles in both versions. A named separator: argument does not compile in 2.0 (CS1739). See Objects for the full format.

Objects​

ToJsonString​

1.x serialized with Newtonsoft.Json. 2.0 uses System.Text.Json and takes an optional JsonSerializerOptions. The output can differ:

  • HTML-sensitive characters (<, >, &, ', ") and non-ASCII characters are escaped as \uXXXX.
  • Public fields are not serialized; only properties are.
  • A reference cycle throws JsonException.
  • Newtonsoft attributes such as [JsonProperty] are ignored. Use [JsonPropertyName] and [JsonIgnore] from System.Text.Json.Serialization.
Before (1.x)
object order = new { Name = "Café <Bistro>", Total = 12.5m };

string json = order.ToJsonString();
Output
{"Name":"Café <Bistro>","Total":12.5}
After (2.0)
using System.Text.Encodings.Web;
using System.Text.Json;

object order = new { Name = "Café <Bistro>", Total = 12.5m };

string json = order.ToJsonString();

JsonSerializerOptions relaxed = new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
string readable = order.ToJsonString(relaxed);
Output
{"Name":"Caf\u00E9 \u003CBistro\u003E","Total":12.5}
{"Name":"Café <Bistro>","Total":12.5}

The escaped and the unescaped forms are both valid JSON and parse to the same values. Use the relaxed encoder only when the JSON is not written into HTML. A null object still returns "null". On net10.0, ToJsonString is marked [RequiresUnreferencedCode] and [RequiresDynamicCode]. See Trimming and AOT and JSON and safe logging.

Is(Type)​

1.x obj.Is(type) compared the exact runtime type. 2.0 uses type.IsInstanceOfType(obj), the same rule as Is<T>() and the is operator: derived types and implemented interfaces match.

Before (1.x)
object error = new ArgumentNullException("invoice");

bool isArgumentError = error.Is(typeof(ArgumentException)); // → false
After (2.0)
object error = new ArgumentNullException("invoice");

bool isArgumentError = error.Is(typeof(ArgumentException)); // → true
bool isExactType = error.GetType() == typeof(ArgumentException); // → false

To keep the old exact-type check, compare GetType() as in the last line.

IsNumber​

In 1.x, IsNumber() on a null object threw ArgumentNullException. In 2.0 it returns false. It also recognizes Half, Int128 and UInt128 on runtimes that have them.

Before (1.x)
object discount = null;

bool numeric = discount != null && discount.IsNumber();
After (2.0)
object? discount = null;
object total = 12.5m;

bool numeric = discount.IsNumber(); // → false
bool money = total.IsNumber(); // → true

IsIn and If​

1.x rejected a null receiver with ArgumentNullException. In 2.0 the receiver can be null. Only the list and the delegate are checked.

Before (1.x)
string couponCode = null;

bool known = couponCode.IsIn("SPRING10", "VIP20"); // throws ArgumentNullException ("item")
After (2.0)
string? couponCode = null;

bool known = couponCode.IsIn("SPRING10", "VIP20"); // → false
string? label = couponCode.If(couponCode is null, _ => "none"); // → "none"

GetPropertyValue and SetPropertyValue​

These methods live in the System.Reflection namespace. In 1.x an unknown property name threw NullReferenceException. In 2.0 it throws ArgumentException with ParamName "propertyName", and the message names the type and the property. SetPropertyValue on a read-only property also throws ArgumentException with that parameter name.

Before (1.x)
using System.Reflection;

Invoice invoice = new Invoice { Number = "INV-7" };

object number = invoice.GetPropertyValue("Nubmer"); // throws NullReferenceException
After (2.0)
using System.Reflection;

Invoice invoice = new Invoice { Number = "INV-7" };

object? number = invoice.GetPropertyValue("Number"); // → "INV-7"
object? typo = invoice.GetPropertyValue("Nubmer"); // throws ArgumentException ("propertyName")

Property names are case-sensitive in both versions. See Objects.

Types​

IsComplex​

IsComplex tells simple values (primitives, enums, strings, dates) apart from objects with properties of their own. 1.x treated DateTimeOffset, TimeSpan and Uri as complex. 2.0 treats them as simple, together with DateOnly, TimeOnly and the nullable versions of these types.

Before (1.x)
bool offset = typeof(DateTimeOffset).IsComplex(); // → true
bool duration = typeof(TimeSpan).IsComplex(); // → true
bool link = typeof(Uri).IsComplex(); // → true
After (2.0)
bool offset = typeof(DateTimeOffset).IsComplex(); // → false
bool duration = typeof(TimeSpan?).IsComplex(); // → false
bool link = typeof(Uri).IsComplex(); // → false
bool invoice = typeof(Invoice).IsComplex(); // → true

If your own code walks object graphs and goes into "complex" properties, it now treats these types as single values. See Types and reflection.

Exceptions​

Invalid arguments​

1.x threw a mix of exception types for bad arguments, including NullReferenceException from inside a method. In 2.0 every method checks its arguments first. It throws ArgumentNullException or ArgumentOutOfRangeException, with ParamName set to the parameter's name. The Conventions page describes the rule.

Before (1.x)
string preview = "Overdue invoice reminder".TruncateWithPostfix(10, null); // throws NullReferenceException
After (2.0)
string? preview = "Overdue invoice reminder".TruncateWithPostfix(10); // → "Overdue..."
string? broken = "Overdue invoice reminder".TruncateWithPostfix(10, null!); // throws ArgumentNullException ("postfix")

Both exception types derive from ArgumentException, so a catch (ArgumentException) block catches them. A catch (NullReferenceException) block around a library call no longer catches anything. Replace it with a check before the call.

The changelog lists the bugs fixed in 2.0 in its "Fixed" section. Some fixes change results too. For example, "HTMLParser".ToKebabCase() now returns "html-parser", MatchAll compares item counts, and Split(0) throws instead of looping forever.

See also​

  • Installation: the package reference and supported target frameworks.
  • Conventions: the null, culture and argument rules that 2.0 applies everywhere.
  • String casing: the word-splitting rules behind the new casing methods.
  • Objects: ToQueryString, Is, IsIn, If and the reflection helpers in full.