Contents

With method for non record types

The with keyword in C# is a relatively recent addition to the language, introduced with C# 9.0. It is a feature that provides a concise and convenient way to create a new object based on an existing object, with some properties changed. This feature is particularly useful when working with immutable objects, such as records, where you cannot modify the object’s state directly after its creation. It is however exclusive to record types. This article tries to address that issue.

Utility method

Have a look at the following method. Property.Ofmethod can be used from Nemesis.EssentialsTypeMeta.Sources source package.

public static TObject With<TObject, TProp>(this TObject settings, Expression<Func<TObject, TProp>> propertyExpression, TProp newValue)
    where TObject : ISettings
{
    static bool EqualNames(string s1, string s2) => string.Equals(s1, s2, StringComparison.OrdinalIgnoreCase);

    var property = Property.Of(propertyExpression);
    var ctor = typeof(TObject).GetConstructors().Select(c => (Ctor: c, Params: c.GetParameters()))
        .Where(pair =>
            pair.Params.Length > 0 &&
            pair.Params.Any(p => EqualNames(p.Name, property.Name))
        )
        .OrderByDescending(p => p.Params.Length)
        .FirstOrDefault().Ctor ?? throw new NotSupportedException("No suitable constructor found");

    var allProperties =
        typeof(TObject).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);

    object GetArg(string paramName)
    {
        if (EqualNames(paramName, property.Name))
            return newValue;
        else
        {
            var prop = allProperties.FirstOrDefault(p => EqualNames(paramName, p.Name))
                ?? throw new NotSupportedException($"No suitable property found: {paramName} +/- letter casing");
            var value = prop.GetValue(settings);
            return value;
        }
    }

    var arguments = ctor.GetParameters()
        .Select(p => GetArg(p.Name)).ToArray();

    return (TObject)ctor.Invoke(arguments);
}

public static class Property
{
    public static PropertyInfo Of<TType, TProp>(Expression<Func<TType, TProp>> memberExpression)
    {
        if (memberExpression.Body is MemberExpression { Member: PropertyInfo property })
            return property;
        else if (memberExpression.Body.NodeType == ExpressionType.Convert &&
                 memberExpression.Body is UnaryExpression { Operand: MemberExpression { Member: PropertyInfo property2 } })
            return property2;
        else
            throw new ArgumentException(@"Only member (property) expressions are valid at this point. Unable to determine property info from expression.", nameof(memberExpression));
    }
}

Example

Consider the following example class along with sample program

#nullable disable
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;


public class Program {
    public static void Main() {
        var settings = CollectionSettings.Default;
        Console.WriteLine(settings);
        //settings.DefaultCapacity = 15; //not possible
        var newSettings = settings.With<CollectionSettings, byte?>(s => s.DefaultCapacity, 15);
        Console.WriteLine(newSettings);
        
        Console.WriteLine(settings); //settings variable is not changed
    }
}

public sealed class CollectionSettings
{
    public char ListDelimiter { get; private set; }
    public char NullElementMarker { get; private set; }
    public char EscapingSequenceStart { get; private set; }
    public char? Start { get; private set; }
    public char? End { get; private set; }
    public byte? DefaultCapacity { get; private set; }

    public CollectionSettings(
        char listDelimiter = '|',
        char nullElementMarker = '∅',
        char escapingSequenceStart = '\\',
        char? start = null,
        char? end = null,
        byte? defaultCapacity = null)
    {
        ListDelimiter = listDelimiter;
        NullElementMarker = nullElementMarker;
        EscapingSequenceStart = escapingSequenceStart;
        Start = start;
        End = end;
        DefaultCapacity = defaultCapacity;        
    }

    public static CollectionSettings Default { get; } = new();
    
    public override string ToString() => $"@{GetHashCode()} {Start}Item1{ListDelimiter}Item2{ListDelimiter}…{ListDelimiter}ItemN{End} escaped by '{EscapingSequenceStart}', null marked by '{NullElementMarker}'. DefaultCapacity = {DefaultCapacity}";
}

Summary

While not perfect, usingWithmethod one can achieve similar semantics thatwithkeyword offers 🚀.

Sources